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.
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>
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]);
});
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', []);
}
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>
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);
});
});
Deploying to Laravel Cloud
This was the easiest part. Laravel Cloud is built for this.
- Push to GitHub:
git push origin main - Connect Account: Log into Laravel Cloud and select the repo.
- Environment: I added my
APP_KEYand setAPP_DEBUG=false. - Deploy: The platform handles the build, SSL, and scaling automatically.
Jason Torres © 2026.