I Tried Building a CMS on Filament — Here's What I'd Do Instead
Architecting Laravel Content Systems: When to Use Admin Frameworks vs. Dedicated CMS Platforms
Current Situation Analysis
The modern Laravel ecosystem heavily favors admin-panel builders for rapid internal tool development. Frameworks like Filament provide exceptional developer experience for scaffolding forms, tables, and relation managers. This success has created a gravitational pull: teams frequently apply these admin-first tools to content-driven projects such as marketing sites, documentation hubs, and editorial blogs. The assumption is straightforward—if you can generate a PostResource in minutes, you have a content management system.
This assumption is architecturally flawed. Admin frameworks optimize for internal data manipulation, role-based access control, and operational workflows. CMS platforms optimize for content lifecycle management, public-facing rendering, SEO infrastructure, and non-technical editorial workflows. When you force an admin framework to handle content delivery, you encounter a silent architectural tax. The initial setup feels productive, but the friction compounds as you implement features that dedicated CMS platforms ship natively.
The problem is overlooked because the developer experience is intentionally streamlined. Form DSLs, relation managers, and media field plugins create the illusion of completeness. The gap only becomes visible when the project requires public routing, sitemap generation, slug history tracking, Open Graph fallbacks, global asset browsing, and comment moderation. Real-world implementations consistently show that scaffolding an admin interface covers roughly 15-20% of a content system’s requirements. The remaining 80% requires custom development. Teams frequently report 6-8 weeks of additional engineering time to bridge this gap, effectively rebuilding foundational CMS features from scratch while maintaining parallel admin and public codebases.
WOW Moment: Key Findings
The critical insight isn’t about framework quality; it’s about domain alignment. Admin frameworks and CMS platforms solve fundamentally different problems. Attempting to merge them creates architectural debt that manifests as delayed delivery, fragmented caching strategies, and inconsistent editorial experiences.
| Approach | Initial Admin Setup | Public Routing & Rendering | SEO & Metadata Pipeline | Media & Asset Management | Editorial Workflow | Total Time to Production |
|---|---|---|---|---|---|---|
| Admin Framework (Filament-style) | 1-2 days | Custom Blade/Inertia build | Manual column mapping + service layer | Per-field attachment only | Developer-dependent | 8-10 weeks |
| Dedicated CMS Platform | 0 days (pre-built) | Theme-driven + route resolution | Native JSON-LD, sitemaps, OG fallbacks | Global library + versioning | Non-technical ready | 2-3 weeks |
This finding matters because it shifts project planning from framework selection to domain classification. When you recognize that content delivery requires a different architectural foundation than internal operations, you stop reinventing routing middleware, cache invalidation strategies, and metadata pipelines. You also eliminate the hidden cost of maintaining two separate rendering contexts (admin vs. public) that inevitably drift out of sync.
Core Solution
Building a content system that scales requires separating admin operations from public delivery. The solution isn’t to avoid admin frameworks; it’s to apply them only where they align with the domain model. When the domain is content, you need a dedicated architecture that handles routing, metadata, media, and editorial workflows as first-class concerns.
Step 1: Classify the Domain Model
Determine whether your data model is operational or editorial. Operational models (Order, Shipment, SupportTicket, InventoryItem) require custom forms, validation rules, and state machines. Editorial models (Article, SitePage, MediaAsset, NavigationMenu) require versioning, public routing, SEO metadata, and non-technical editing capabilities. This classification dictates your architectural foundation.
Step 2: Architect Public vs Admin Routing
Admin frameworks handle routes within a protected panel. Public content requires a separate routing layer that resolves slugs, handles fallbacks, and applies cache headers. Instead of scattering route definitions across controllers, implement a centralized content router that delegates to a resolver service.
// app/Services/ContentRouter.php
namespace App\Services;
use App\Models\PublishedContent;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ContentRouter
{
public function resolve(string $uri): PublishedContent
{
$cacheKey = "content_route_{$uri}";
return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($uri) {
$record = PublishedContent::query()
->where('slug', $uri)
->orWhereHas('slugHistory', fn($q) => $q->where('legacy_slug', $uri))
->first();
if (!$record) {
throw new NotFoundHttpException("Content not found for URI: {$uri}");
}
return $record;
});
}
public function invalidate(string $uri): void
{
Cache::forget("content_route_{$uri}");
}
}
Why this choice: Centralizing route resolution prevents duplicate slug-lookup logic across controllers. The cache layer uses a short TTL with explicit invalidation on content updates, balancing performance with editorial freshness. The slugHistory relationship handles legacy URLs without middleware bloat.
Step 3: Implement a Metadata & SEO Pipeline
SEO requirements extend far beyond meta_title and meta_description columns. You need JSON-LD schema injection, Open Graph fallbacks, canonical URL resolution, and sitemap generation. Treat metadata as a pipeline rather than static database fields.
// app/Services/SeoMetadataPipeline.php
namespace App\Services;
use App\Models\PublishedContent;
use Illuminate\Support\Str;
class SeoMetadataPipeline
{
public function generate(PublishedContent $content): array
{
return [
'title' => $content->seo_title ?? $content->headline,
'description' => $content->seo_description ?? Str::limit($content->body, 160),
'canonical' => route('public.content.show', $content->slug),
'og_image' => $this->resolveOgImage($content),
'json_ld' => $this->buildSchema($content),
'robots' => $content->no_index ? 'noindex, nofollow' : 'index, follow',
];
}
private function resolveOgImage(PublishedContent $content): string
{
return $content->featured_image?->url
?? config('app.fallback_og_image');
}
private function buildSchema(PublishedContent $content): array
{
$type = match ($content->type) {
'article' => 'Article',
'page' => 'WebPage',
default => 'Thing',
};
return [
'@context' => 'https://schema.org',
'@type' => $type,
'headline' => $content->headline,
'datePublished' => $content->published_at->toIso8601String(),
'author' => ['@type' => 'Person', 'name' => $content->author->name],
];
}
}
Why this choice: Metadata generation is decoupled from the model. This allows you to swap fallback strategies, inject dynamic schema types, and maintain consistent OG/SEO behavior across all content types without polluting Eloquent models with presentation logic.
Step 4: Design the Asset & Workflow Layer
Content systems require global media browsing, versioning, and editorial permissions. Admin frameworks typically attach media per-field. A proper CMS architecture uses polymorphic relations with a centralized asset registry, paired with a workflow state machine for drafts, reviews, and scheduling.
// app/Models/MediaAsset.php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MediaAsset extends Model
{
use HasFactory;
protected $fillable = ['file_path', 'alt_text', 'width', 'height', 'mime_type'];
public function assetable(): MorphTo
{
return $this->morphTo();
}
public function scopeGlobalLibrary($query)
{
return $query->whereNull('assetable_id');
}
}
Why this choice: Polymorphic attachments allow assets to exist independently of specific content records. The globalLibrary scope enables editors to search and reuse historical media without navigating through individual post forms. This mirrors established CMS patterns while keeping the database normalized.
Pitfall Guide
1. The Resource Scaffolding Illusion
Explanation: Defining PostResource or PageResource in an admin framework creates the visual appearance of a CMS. This masks the absence of public routing, SEO pipelines, and editorial workflows. Teams mistake form generation for content management.
Fix: Treat admin scaffolding as strictly internal. Implement a separate content delivery layer with explicit routing, caching, and metadata handling before considering the project complete.
2. Hardcoding SEO Columns on Every Model
Explanation: Adding seo_title, meta_desc, and og_url to every content model creates schema bloat and inconsistent fallback behavior. It also forces duplicate validation logic across resources.
Fix: Abstract metadata into a dedicated service or trait. Use a pipeline approach that resolves fallbacks, canonical URLs, and schema injection at render time, keeping models focused on data storage.
3. Ignoring Slug History & Redirect Middleware
Explanation: When editors rename a post slug, old URLs return 404 errors. Search engines penalize broken links, and users lose bookmarked content. Building this later requires retroactive middleware and database migrations.
Fix: Implement a SlugHistory model with a composite index on legacy_slug and current_slug. Register early-request middleware that checks the history table before throwing NotFoundHttpException. Cache lookups to avoid N+1 queries.
4. Building Custom Media Browsers from Scratch
Explanation: Attempting to recreate a global media library using admin framework repeaters or custom Livewire components results in poor UX, missing features (drag-and-drop, search, versioning), and maintenance overhead. Fix: Use established media packages that support polymorphic relations, cloud storage drivers, and image optimization. Integrate them into a centralized asset registry rather than scattering uploads across form fields.
5. Mixing Admin and Public Concerns in Controllers
Explanation: Using the same controller to render admin previews and public pages creates conditional logic bloat. Admin routes require authentication and draft visibility; public routes require caching and SEO headers. Merging them breaks separation of concerns.
Fix: Create distinct controller namespaces (App\Http\Controllers\Admin vs App\Http\Controllers\Public). Route them through separate middleware groups. Share only the underlying service layer for data retrieval.
6. Overlooking Editorial Permission Granularity
Explanation: Admin frameworks default to role-based access (admin, editor, viewer). Content systems require workflow-specific permissions: draft creation, review approval, scheduling, and SEO metadata editing. Flat roles cause permission leaks or workflow bottlenecks.
Fix: Implement a policy-based permission system tied to content states. Use gates or policies that evaluate canEditDraft, canApproveReview, and canPublishScheduled. Decouple permissions from user roles where possible.
7. Neglecting Cache Invalidation Strategies
Explanation: Public content routes are often cached aggressively to improve performance. When editors update content, stale pages remain visible until cache expiration. This breaks editorial trust and causes support tickets.
Fix: Implement event-driven cache invalidation. Listen to ContentUpdated or ContentPublished events and purge specific route caches, sitemap caches, and CDN edges. Use stale-while-revalidate patterns for high-traffic pages.
Production Bundle
Action Checklist
- Classify domain model: Determine if the project requires operational data management or editorial content delivery
- Separate routing layers: Create distinct admin and public route files with appropriate middleware groups
- Implement slug history: Add a
SlugHistorymodel with early-request middleware and cache-aware lookups - Abstract metadata pipeline: Build a dedicated SEO service with fallback resolution and JSON-LD generation
- Centralize media management: Use polymorphic relations with a global asset registry instead of per-field attachments
- Define editorial workflows: Implement state machines for drafts, reviews, scheduling, and publishing
- Configure cache invalidation: Set up event listeners to purge route, sitemap, and CDN caches on content updates
- Audit permission granularity: Replace flat roles with policy-based gates tied to content states and workflow stages
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Internal operations dashboard (orders, shipments, support) | Admin Framework (Filament) | Custom data model requires flexible form/table DSL; no public rendering needed | Low (1-2 weeks) |
| Marketing site with blog, pages, SEO, media | Dedicated CMS Platform | Editorial workflow, public routing, and SEO are first-class concerns; admin framework requires 6-8 weeks of reinvention | Medium (2-3 weeks) |
| SaaS product with user-facing settings & billing | Admin Framework + Multi-Panel | User-specific data requires isolated panels; content delivery is not the primary domain | Low-Medium (2-4 weeks) |
| Documentation hub with versioned articles | Dedicated CMS or Static Generator | Content lifecycle, search, and public routing are critical; admin scaffolding lacks versioning & SEO pipelines | Medium (3-4 weeks) |
| Hybrid: Custom admin + public blog | Split Architecture | Use admin framework for internal ops; integrate a lightweight CMS or headless solution for public content | Medium-High (4-6 weeks) |
Configuration Template
// config/content-delivery.php
return [
'routing' => [
'cache_ttl' => 900, // 15 minutes
'stale_while_revalidate' => 3600,
'slug_history_enabled' => true,
'fallback_locale' => 'en',
],
'seo' => [
'default_og_image' => env('APP_URL') . '/images/fallback-og.jpg',
'json_ld_enabled' => true,
'sitemap_cache_ttl' => 43200, // 12 hours
'robots_txt_path' => resource_path('views/robots.blade.php'),
],
'media' => [
'driver' => env('MEDIA_DISK', 's3'),
'image_optimization' => true,
'max_upload_size_mb' => 50,
'allowed_mime_types' => ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'],
],
'workflow' => [
'states' => ['draft', 'review', 'scheduled', 'published', 'archived'],
'auto_publish_timezone' => 'UTC',
'require_approval_for' => ['published', 'scheduled'],
],
];
Quick Start Guide
- Initialize the project structure: Create separate directories for admin and public concerns (
app/Services/Admin,app/Services/Public,routes/admin.php,routes/public.php). - Configure content routing: Register the
ContentRouterservice in a service provider. Bind it to a singleton and attach it to a dedicated public route group with caching middleware. - Set up the SEO pipeline: Publish the
content-delivery.phpconfig. Implement theSeoMetadataPipelineservice and inject it into your public layout view. Ensure JSON-LD and OG tags render conditionally based on content type. - Enable slug history & cache invalidation: Create the
SlugHistorymigration and model. Register an event listener onContentPublishedthat callsContentRouter::invalidate()and clears sitemap cache. - Verify editorial workflow: Test draft creation, slug changes, and scheduled publishing. Confirm that public routes resolve correctly, SEO metadata renders accurately, and cache invalidation triggers on updates.
Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register — Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
