Back to Blog · Software Architecture

Serving a Blog API from a Laravel App That Also Runs Filament

Adding a REST API to a Laravel site already running Filament admin without conflicts, extra packages, or over-engineering. Token auth, auto-slug generation, markdown table support, and draft previews.

MF
Martin Fournier
· June 02, 2026 · 6 MIN READ
Illustration for: Serving a Blog API from a Laravel App That Also Runs Filament

Adding a JSON API to a Laravel app that already runs Filament sounds straightforward. Laravel has all the pieces. In practice, the friction points are subtle. Token auth without pulling in Sanctum. Auto-generated slugs that don't collide. Markdown rendering that handles tables. A sorting strategy that works for both published and unpublished posts. Draft previews without exposing unpublished content to the public.

This post walks through each decision on martinfournier.com, a Laravel 13 site with Filament v3 admin. No packages beyond what ships with Laravel. No over-engineering. Just enough API surface to let automation agents create and manage blog content programmatically.

Route Design

Blog API Architecture

The blog API architecture: AI agents and cron jobs authenticate via Bearer token through BlogApiAuth middleware, access six RESTful endpoints, while the Filament admin panel and signed URL preview share the same SQLite database.

The API lives under /api/blog/ with a shared middleware alias. No version prefix. For a single-consumer API -- in this case, an AI agent cron job -- versioning is premature complexity.

// routes/api.php
Route::prefix('blog')->middleware('auth.blog-api')->group(function () {
    Route::get('/posts', [BlogPostController::class, 'index']);
    Route::get('/posts/{id}', [BlogPostController::class, 'show']);
    Route::post('/posts', [BlogPostController::class, 'store']);
    Route::put('/posts/{id}', [BlogPostController::class, 'update']);
    Route::put('/posts/{id}/content', [BlogPostController::class, 'updateContent']);
    Route::delete('/posts/{id}', [BlogPostController::class, 'destroy']);
});

Six endpoints. Index returns a paginated list with summary fields only. Show returns everything including full content. Store validates and creates. The /content sub-endpoint exists so the agent can update the body of a draft without re-sending all fields.

Token Auth in Thirty Lines

Sanctum is the default Laravel choice for API auth. It also brings a migrations table, cookie-based SPA auth, token expiration, and a full database-backed model. For a single cron agent hitting a single endpoint group, that's unnecessary machinery.

The middleware is a hash-compare against an env variable:

class BlogApiAuth
{
    public function handle(Request $request, Closure $next): Response
    {
        $token = config('blogapi.token');

        if ($token === null || $token === '') {
            return response()->json(['error' => 'API not configured'], 500);
        }

        if (! hash_equals($token, $request->bearerToken())) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        return $next($request);
    }
}

Registered in bootstrap/app.php as auth.blog-api. The token lives as a Fly.io secret, read by the config file. hash_equals prevents timing attacks on the comparison. The 500 response for a missing token is deliberate: if the env var isn't set, something is wrong and the caller needs to know immediately, not silently fail.

Auto-Slug with Inline Fallback

The store endpoint accepts an optional slug. If omitted, it generates one from the title:

if (empty($validated['slug'])) {
    $validated['slug'] = Str::slug($validated['title']);
}

Str::slug handles transliteration and produces URL-safe output. The database unique constraint catches collisions. For a single-agent workflow, collisions are rare enough that an inline fallback beats a recursive retry loop.

If collisions become an issue, the next step is appending a random suffix on failure, not switching to UUIDs. UUID slugs are ugly and waste URL real estate.

Markdown Rendering with CommonMark

Content is stored in Markdown and rendered server-side with league/commonmark, which ships with Laravel. The base renderer doesn't support tables out of the box. Adding them requires the TableExtension:

use League\CommonMark\Extension\Table\TableExtension;

$converter = new CommonMarkConverter([
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);
$converter->getEnvironment()->addExtension(new TableExtension);

The allow_unsafe_links: false strips javascript: URLs and other XSS vectors. The rendered output goes through Illuminate\Support\Str::markdown() which wraps this same converter under the hood since Laravel 10.

For the frontend, tables need dark-mode CSS:

.prose table {
    @apply w-full border-collapse;
}
.prose th {
    @apply border border-amber-900/30 px-4 py-2 bg-amber-900/10 font-semibold text-left;
}
.prose td {
    @apply border border-zinc-800 px-4 py-2;
}

Sorting with COALESCE

The index endpoint needs a deterministic sort order that works whether a post is published or still a draft. The naive approach is ORDER BY published_at DESC, but drafts have a null published_at and would sort to the bottom unpredictably.

The solution is COALESCE(published_at, created_at). Drafts fall back to their creation timestamp. The sort is always predictable and always descending -- newest content first, regardless of status.

BlogPost::select([...])
    ->orderByRaw('COALESCE(published_at, created_at) DESC')
    ->get();

This pattern also powers the public blog listing, the year filter, and the category archive. It's defined once in the scopePublished() scope and reused everywhere.

Draft Previews

Unpublished drafts need a preview route that doesn't expose them to search engines or casual visitors. The solution is a preview route with a signed URL:

Route::get('/blog/preview/{slug}', [BlogController::class, 'preview'])
    ->middleware('signed');

Laravel's signed middleware validates a hash parameter appended to the URL. The preview link is generated in the Filament admin edit page and only visible to authenticated admin users. The controller checks the signature, finds the post by slug regardless of published status, and renders it in the same template as the live post.

No separate staging environment needed. No .env toggle. Just a cryptographically signed URL that expires when the page is regenerated.

What Was Left Out

  • Rate limiting: Single-agent consumer, no public registration. Not needed.
  • Pagination cursors: The index endpoint returns all posts. When the count exceeds 50, adding ->simplePaginate(50) is a one-line change. Until then, premature.
  • API versioning: No public consumers. A single agent that can be updated atomically. Breaking changes are handled by updating both sides simultaneously.
  • Response resources: Returning $post directly from Eloquent works for the current consumer schema. API resource classes add a layer when shaping becomes necessary. Not yet.

The Pattern

A focused API for a single automated consumer doesn't need a framework package. It needs:

  1. A middleware that checks a bearer token against an env secret
  2. A controller with validated input and predictable sorting
  3. A rendering pipeline that handles the content format
  4. A preview mechanism that keeps drafts private

Everything else is optional. Add it when the consumer demands it, not before.

That approach keeps the API surface small, the deployment simple (one BLOG_API_TOKEN on Fly.io), and the codebase easy to reason about. The API is a side effect of the existing Laravel app, not a separate concern.