Back to Home

Building a "Headless" Portfolio in Laravel

The Mission

I needed a one-stop shop. My content was scattered—blogs on Hashnode, talks on YouTube, code on GitHub. I wanted to own the experience without losing the benefits of those platforms.

Most importantly, I wanted to build it in Laravel—the ecosystem I love—and deploy it to the new Laravel Cloud.

Step 1

The Setup

We start with a standard Laravel installation. We don't need a heavy frontend framework like React or Vue for this. We just need Alpine.js for the interactive "tabs" on the home screen.

# 1. New Project (with Git initialized)
laravel new portfolio --git --no-interaction

# 2. Add Alpine.js (in head tag)
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Step 2

The "Headless" Blog Engine

I write on Hashnode because the editor is great. I didn't want to copy-paste articles into my database. Instead, I treat Hashnode as a Headless CMS.

We use Laravel's HTTP client to fetch posts via GraphQL. This happens in routes/web.php.

use Illuminate\Support\Facades\Http;

Route::get('/', function () {
    // 1. The Query: Fetch top 20 posts with metadata
    $query = <<<'GQL'
    query GetPosts($host: String!) {
        publication(host: $host) {
            posts(first: 20) {
                edges {
                    node {
                        title
                        brief
                        slug
                        publishedAt
                    }
                }
            }
        }
    }
    GQL;

    // 2. The Request: Hit Hashnode's API
    $response = Http::post('https://gql.hashnode.com', [
        'query' => $query,
        'variables' => [
            'host' => 'film2code.vercel.app' // My Hashnode Domain
        ]
    ]);

    // 3. Pass data to the view
    $posts = $response->json('data.publication.posts.edges', []);

    // ... (YouTube logic continues below)

    return view('welcome', ['posts' => $posts]);
});
Step 3

Dynamic YouTube Streams

Initially, I hardcoded my YouTube links. But that's manual work. I upgraded the route to fetch my latest videos automatically using the YouTube Data API.

The Trick: Instead of searching for videos (expensive API cost), I convert my Channel ID (`UC...`) to an Uploads Playlist ID (`UU...`) and fetch that playlist. It's faster and cheaper.

// Inside the same Route::get('/') function...

$apiKey = env('YOUTUBE_API_KEY');
$channelId = env('YOUTUBE_CHANNEL_ID');

if ($apiKey && $channelId) {
    // TRICK: Change 'UC' to 'UU' to get the Uploads Playlist
    $uploadsPlaylistId = 'UU' . substr($channelId, 2);

    $youtubeResponse = Http::get('https://www.googleapis.com/youtube/v3/playlistItems', [
        'part' => 'snippet',
        'playlistId' => $uploadsPlaylistId,
        'maxResults' => 5,
        'key' => $apiKey,
    ]);

    $videos = $youtubeResponse->json('items', []);
}
Step 4

The Multimedia Tabs

A portfolio isn't just writing. I needed a place for my YouTube streams and Conference Talks.

We use Alpine.js to toggle visibility between these sections without reloading the page.

<!-- Alpine State Wrapper -->
<div x-data="{ activeTab: 'writing' }">

    <!-- 1. Writing Tab (Dynamic) -->
    <div x-show="activeTab === 'writing'">
        @foreach($posts as $edge)
                                <a href="/blog/{{ $edge['node']['slug'] }}">
                                {{ $edge['node']['title'] }}
                                </a>
                            @endforeach
    </div>

    <!-- 2. Streaming Tab (Dynamic YouTube Cards) -->
    <div x-show="activeTab === 'streaming'">
        @foreach($videos as $video)
                                <a href="https://youtube.com/..." class="card">
                                <div class="badge">{{ $video['snippet']['channelTitle'] }}</div>
                                <h3>{{ $video['snippet']['title'] }}</h3>
                                </a>
                            @endforeach
    </div>

</div>
Step 5

Why We Needed JavaScript

The Problem

Hashnode returns raw HTML. Their code blocks are simple <pre> tags. They look boring.

The Solution

I couldn't change the HTML coming from the API easily. So, I wrote a JavaScript DOM Manipulation script. It runs on page load, finds every boring <pre> tag, and "wraps" it inside a custom DIV structure that looks like a Mac Terminal window.

Here is the script that powers the code blocks you are reading right now:

document.addEventListener("DOMContentLoaded", () => {
    // 1. Find all raw code blocks
    const codeBlocks = document.querySelectorAll('.content pre');

    codeBlocks.forEach(pre => {
        // 2. Create the "Mac" Header
        const header = document.createElement('div');
        header.className = "terminal-header";
        header.innerHTML = `<span class="dot red"></span><span class="dot yellow"></span>`;

        // 3. Create the Wrapper
        const wrapper = document.createElement('div');
        wrapper.className = 'terminal-window';

        // 4. Move the <pre> inside the wrapper
        pre.parentNode.insertBefore(wrapper, pre);
        wrapper.appendChild(header);
        wrapper.appendChild(pre);
    });
});
Step 6

Deploying to Laravel Cloud

This was the easiest part. Laravel Cloud is built for this.

  1. Push to GitHub: git push origin main
  2. Connect Account: Log into Laravel Cloud and select the repo.
  3. Environment: I added my APP_KEY and set APP_DEBUG=false.
  4. Deploy: The platform handles the build, SSL, and scaling automatically.

Jason Torres © 2026.