Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Page Bundle Laravel Package

stfalcon/page-bundle

Symfony bundle integrating the StfalconPageBundle CMS features into Symfony applications, providing page management, routing, and admin tooling. Compact but niche, with limited adoption.

View on GitHub
Deep Wiki
Context7

Getting Started

First, reset expectations. This is a Symfony 2.x bundle with SonataAdmin coupling — not a Laravel package. You cannot composer require stfalcon/page-bundle into a Laravel app and get anything useful. The bundle is worth ~150 lines of domain code: a Page entity with slug, title, body, SEO fields, and a publish flag, plus an admin CRUD and a slug-based front-end route. That's what you're evaluating. Treat it as a spec, not a dependency.

What to read first, in order:

  1. Entity/Page.php — the canonical schema. Copy the field set: id, slug, title, content, metaTitle, metaDescription, published, timestamps. Optionally locale if you plan to grow.
  2. Admin/PageAdmin.php — how the Sonata form is wired. Read it for field grouping (content vs. SEO) and for the prePersist/preUpdate slug auto-generation pattern.
  3. Resources/config/routing.yml + the front controller — the /{slug} catch-all route. This is the bit you'll reimplement in Laravel with Route::get('{slug}', PageController::class) at the bottom of your route file.
  4. Resources/views/ — Twig templates. Skim for the fallback behavior when a template isn't found.

First use case (Laravel equivalent in ~half a day):

php artisan make:model Page -mf
php artisan make:controller PageController
php artisan make:filament-resource Page  # or `php artisan nova:resource Page`

Migration:

Schema::create('pages', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique();
    $table->string('title');
    $table->text('content');
    $table->string('meta_title')->nullable();
    $table->string('meta_description')->nullable();
    $table->boolean('published')->default(false);
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});

Controller:

public function show(string $slug)
{
    $page = Page::where('slug', $slug)
        ->where('published', true)
        ->firstOrFail();

    return view('pages.show', compact('page'));
}

That's the whole feature. Don't spend more than a day deciding — build it.

Implementation Patterns

Schema and model. Keep the bundle's field names when porting (title, content, meta_title, meta_description, published) so any future migration or export from the Symfony side maps cleanly. Add published_at even if the bundle doesn't have it — you'll want it for ordering and scheduled publishing later. Use a unique index on slug (and (locale, slug) if you add locales).

Slug auto-generation on save. The bundle does this in the Admin class (prePersist/preUpdate). In Laravel, do it in the model so it holds no matter how the page is created:

protected static function booted(): void
{
    static::saving(function (Page $page) {
        if (blank($page->slug)) {
            $page->slug = Str::slug($page->title);
        }
    });
}

Admin resource. Map the bundle's Sonata field groups onto Filament or Nova:

  • Group 1 (Content): title, slug, content (RichEditor/Trix), published.
  • Group 2 (SEO): meta_title, meta_description — collapse by default.

Hide the slug under a "regenerate from title" action rather than exposing it raw; editors will thank you.

Rendering. The bundle uses a single Twig template. In Laravel, expose a per-page template column (template string) so marketing can pick between pages.default, pages.landing, pages.legal without a deploy:

public function show(string $slug)
{
    $page = Page::where('slug', $slug)->where('published', true)->firstOrFail();
    $view = $page->template ?: 'pages.default';
    abort_unless(view()->exists($view), 404);
    return view($view, compact('page'));
}

SEO metadata in the layout. The bundle's whole point is meta-title/description support. Wire this in your base layout:

<title>{{ $page->meta_title ?? $page->title }} — {{ config('app.name') }}</title>
<meta name="description" content="{{ $page->meta_description ?? Str::limit(strip_tags($page->content), 155) }}">

Caching. Slug lookup is read-heavy and write-light. Cache at the model layer:

$page = Cache::remember("page:{$slug}", 3600, fn () => Page::where('slug', $slug)->where('published', true)->first());
abort_if($page === null, 404);

Forget the key in saving/deleted model events. Don't cache the 404 case — otherwise unpublished content stays unpublished in the wrong way (see below).

Routing. Do not put the catch-all at the top of routes/web.php. Register it inside a middleware group and after your other routes, or wrap it with a negative-match prefix list (Route::get('{slug}', ...)->where('slug', '^(?!admin|api|_debugbar).*$')).

Gotchas and Tips

Catch-all route collisions. The single largest friction point. The bundle puts /{slug} first-class; in a real Laravel app that route will swallow /login, /dashboard, /sitemap.xml, and anything else you forget to declare before it. Always register the catch-all last, always constrain the slug regex ([a-z0-9-]+), always exclude reserved prefixes. Test /admin explicitly after adding the route.

Unpublished leakage. The bundle's default route does a bare slug lookup. If you copy that pattern you'll serve draft pages. Every read path must filter published = true — controller, sitemap, search, cache key, admin preview. Add an explicit feature test for it:

public function test_unpublished_page_returns_404(): void
{
    $page = Page::factory()->create(['slug' => 'draft', 'published' => false]);
    $this->get('/draft')->assertNotFound();
}

Slug uniqueness under edits. Sonata's admin form silently creates duplicate slugs if you don't enforce at the DB layer. Laravel's Str::slug($title) produces collisions when two pages share a title (about, about-2). Either validate unique:pages,slug,{id} on save, or use a helper that appends a suffix until unique. Never rely on the form to catch it.

Locale and duplication. The bundle has no i18n story. If you need en/fr/de later, bolt a locale column on before content ships, not after — retrofitting means backfilling every slug and rewriting every route. The catch-all becomes Route::get('{locale}/{slug}', ...) bounded to [a-z]{2}, with app()->getLocale() as the fallback.

Template extension via config. The bundle exposes the template path as a service parameter, not per-page. If you want per-page templates in your Laravel port, add the template column as above, but also add a view-layer guard (view()->exists) so a bad template name doesn't produce a 500 — it should degrade to pages.default or 404. Editors will typo template names.

HTML content and XSS. The bundle trusts content as safe HTML because it comes from an authenticated Sonata admin. If your page table is ever writable by a lower-privileged user (e.g., an editor role added later), you've built a stored-XSS vector. Blade's {!! $page->content !!} is fine only if the write path is admin-only and you sanitize on save (HTMLPurifier or strip_tags with an allowlist). Document that assumption next to the column.

Route cache invalidation. If you deploy with php artisan route:cache, remember that a new Page in the DB does not require a route cache rebuild — but a new reserved prefix or controller change does. This is the difference between "publish and it's live" and "publish and page 404s until deploy," which is the exact failure mode the original bundle avoided and you don't want to reintroduce.

Admin as a security surface. The bundle leans on SonataAdmin, an authenticated high-privilege panel, to keep content safe. Your Filament/Nova replacement inherits that assumption — put it behind a gate (can:manage-pages), not just auth.

Forking/porting rules. If you do want to reference the bundle's code directly, note its constraints: Symfony 2.x, PHP 5.6+, Doctrine annotations, Twig 1.x. It contains no tests, no CHANGELOG, and single-digit stars. Copy the pattern, not the code. If you must vendor it for a legacy migration, pin every transitive dependency and assign an internal owner before the first commit — you will own it through every subsequent PHP upgrade.

Extensibility worth copying. The one genuinely reusable idea from the bundle is the Page entity being the base for custom page types (landing pages, FAQ, case studies) via a type discriminator. If you expect more than five page kinds, put a type string on the table from day one and dispatch rendering on it — adding it later means migrating the front-end controller and every template.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings