folklore/laravel-panneau
folklore/laravel-panneau provides a lightweight, configurable panel layer for Laravel applications, helping you build and organize admin screens, dashboards, and management interfaces with familiar conventions and minimal setup.
# requirements first: PHP 8.3+, Laravel 12 or 13
composer require folklore/laravel-panneau:^5.0@alpha
php artisan panneau:install
php artisan route:list --name=panneau
This is not an incremental upgrade — 5.x is a rewrite, and it is the reference implementation of the Panneau protocol from @panneau/protocol. Treat it as a new package with a familiar name. Read in this order:
vendor/folklore/laravel-panneau/docs/ — the new docs directory, not the README, is the canonical source now.vendor/folklore/laravel-panneau/README.md — install, the panneau:install command, and the alpha scope.@panneau/protocol (linked from the README, tagged v5.x) — the OpenAPI document and the definition format. This is the actual specification your resources must satisfy; the PHP source implements it.v5.x/docs/decisions/ — the release notes cite them directly (0030 search parameters and scope, 0032 field checks and slugs). They are the why behind the API shapes, and they are more stable than the alpha code.vendor/folklore/laravel-panneau/src/ — the ResourceRepository contract and EloquentRepository first, then ResourceQuery, then the resource/field/layout/list/action builders, then the search and field-check endpoints added in alpha.4.routes/ inside the package — routes are opt-in and middleware-driven, so where and how they get registered is a decision you make, not a default you inherit.First useful milestone: one resource, read-only, end to end. Declare a resource with the builders, point it at an EloquentRepository, attach a policy, expose GET /definition, list, and show behind your existing admin middleware, and confirm the responses validate against the protocol's OpenAPI document. That exercises the whole spine — builders, repository adapter registration, ResourceQuery filtering, policy enforcement, error shape, and the definition endpoint — and it is a much better use of the first day than chasing CRUD. Do not start with create/update/delete or actions; optimistic concurrency and selection handling are where the alpha is least settled.
Second milestone, once the read path is green: global search plus one slug-checked field. GET {panneau.path}/api/search?q=… and POST resources/{resource}/fields/{field}/check are the two newest surfaces in alpha.4 and both cut across policies, repositories, and rate limiting — they make excellent canaries for whether your wiring is actually right.
Pin the exact alpha version (5.0.0-alpha.4, no caret), commit the lock file, and pin the matching @panneau/* JS version (5.0.0-alpha.5). The release notes say the API can still change before 5.0.0 — with an alpha, a ^ constraint is a live grenade in your admin. The PHP and JS packages are versioned independently and must be bumped together; alpha.4 of the PHP side is explicitly paired with alpha.5 of @panneau/*.
Resources are repository-backed, not model-backed. The ResourceRepository contract is the seam: EloquentRepository is the shipped adapter, and adapters are registered by contract. That means your day-to-day pattern is "declare a resource class, register its repository, register its policy" — not "write a controller." Keep one resource per domain concept (Users, Orders, Invoices), and put non-trivial query logic inside your repository implementation rather than in the resource definition, so you can unit-test it without the HTTP layer.
Declare fields, filters and sorts deliberately — that declaration is your security boundary. ResourceQuery only accepts the filters and sorts a resource declares, and items are serialized only from declared fields. That is a genuinely good design, and it means an accidental ->sortable('*') or a field left in the definition is the equivalent of a mass-assignment bug. Review resource definitions the way you review $fillable. Never declare a field "just in case"; add it when a screen needs it. As of alpha.4 the same declaration also drives global search, so an over-broad definition now leaks into an endpoint you didn't write.
Policies are required, so plan them per resource up front. The release notes are explicit: policies are required, and only validated data reaches the repository. Create the policy at the same time as the resource — a resource without one is a landmine you will find in staging. Use policy methods for row-level scoping and keep field-level hiding in the definition (undeclared fields are never serialized). Test the deny paths, not just the allow paths. Global search is a good reminder of why: it searches only the resources the user can list, so a missing or permissive viewAny silently widens the search surface.
Actions are your write path; validate the selection. Actions accept selections by ids or by query, which is powerful and easy to abuse — a query-based selection can silently expand to rows the caller can't see if your policy isn't applied at selection time. Bind the selection to the policy and re-check inside the action. Prefer ids for anything destructive; treat query selections as "all matching, subject to policy" and say so in your UI copy.
Global search is scoped search, not a site-wide grep. GET {panneau.path}/api/search takes q, limit and resources, and iterates only resources the user can list, going through their repositories — or through Laravel Scout for models that use it. Practical consequences:
panneau-search rate limiter. Check the limiter definition in your config and tune it for your traffic; a search-as-you-type UI will hit it.panneau.search.enabled. It is on by default in alpha.4 — if you don't want the endpoint, turn it off explicitly rather than relying on the front-end not calling it.laravel/scout is suggested, not required. If your models are Scout-indexed, expect search results to come from the index and therefore diverge from what the resource's repository would return (filters, soft deletes, policy scoping). Decide which behaviour you want and document it.ResourceQuery test below.Slugs are now verified, and non-Eloquent repositories have to opt in. Slug::unique() checks uniqueness at save time, per locale, with optional narrowing; url() supplies the URL the field displays; disabledWhen() makes the field read-only for a given item (home pages, system routes). The typical shape:
Slug::make('slug')->from('title')->localized()
->unique(fn (Builder $query, array $values) => $query->where('parent_id', $values['parent'] ?? null))
->url(fn (string $slug, ?string $locale, array $values, ?string $id): ?string => url("/{$locale}/{$slug}"));
If your repository is not Eloquent, implement ChecksSlugs — otherwise the check endpoint has nothing to answer with. Uniqueness is per locale, so a localized slug needs a test per locale, and narrowed uniqueness (per parent) needs a test that the same slug under a different parent is accepted.
The field check endpoint is a live lookup, so treat it as one. POST resources/{resource}/fields/{field}/check is what powers as-you-type slug validation in the form. It is a public-ish endpoint under your admin middleware that takes user input and queries your repository — make sure it is behind the same guard as the rest of the resource routes, and that it can't be used to enumerate titles or slugs you wouldn't otherwise show.
Media replacement is a first-class operation now. A mediatheque library can replace a media's original via Mediatheque::replaceOriginal(): the media keeps its id and is reprocessed. A refused file, a file of another type, or one outside the library's allowed types returns 422 and leaves the media untouched; replace() is the opt-in that lets a library allow type changes. This requires folklore/laravel-mediatheque 2.0.0-alpha.5 or later. Practically: the file field of a MediathequeResource is no longer createOnly(), so re-check any resource definition or client code that assumed the original was immutable. Use Mediatheque::fake() in tests — it documents why it refuses a media and how to assert refusals.
Lean on the contract tests as your upgrade harness. Every API response is checked against the protocol's OpenAPI document in the package's CI, and the protocol's conformance kit is available. As of alpha.4 the conformance checks include global search and fields.check, so the harness covers the new surfaces too. Run the conformance kit against your resources in your own CI. It is the only cheap way to find out that an alpha bumped the protocol shape, and it converts "the admin broke" into a red build.
ETag/If-Match is real optimistic concurrency — build the retry into your client. 412 on conflict is correct behaviour and will happen in any long-lived admin tab. Decide now: refetch-and-reapply, or surface a conflict prompt. Also make sure your resource serialization is deterministic, or your ETags will churn and every save will 412.
Errors are application/problem+json. Don't rely on Laravel's default JSON error shape; map your exceptions to the problem format and check that validation failures and policy denials both come out as proper problem documents. A refused media replacement should arrive as a proper 422 problem document, not a bare Laravel validation blob. Any HTML error page leaking to a protocol client will break the front-end in a confusing way.
Routes are opt-in — register them yourself, with your middleware. Put the registration in your own service provider or route file, behind your admin guard, with an explicit prefix and middleware stack. Don't assume the install command wires them exactly the way you want, and check route:list --name=panneau after install for duplicates or missing middleware. Since alpha.4 you can mount only the parts of the API you use — Panneau::resourceRoutes($resources, only: [...]) and Panneau::uploadRoutes(), without the app page — which is exactly what you want in a project with its own admin front-end. Route parameters are now read by name, so a tenants/{tenant}/... prefix works: make sure the middleware stack resolves and authorises that tenant before the Panneau controllers see it.
The front-end is a separate decision. The alpha ships an admin page and a JS entry, but 5.x is API-first: pages and menus are not in the definition yet, and there is no roles module. For anything you'd actually put in front of staff, plan on either a thin custom front-end against the API, or waiting for the beta. The PHP side is usable now; the out-of-the-box admin experience is not the finished article.
Do not fork — the protocol is the boundary. The one genuinely good thing about this rewrite is that the contract is now external and versioned. If you need behaviour the package lacks, implement it in your repository, your policy, or your own front-end. That keeps the option of migrating to another protocol implementation, or to your own, without rewriting your admin.
There is no upgrade path from 4.x, and the migration guide does not exist yet. The release notes list it under "not in this alpha." If you are running a 4.x Blade dashboard with widgets, layouts, and menus, 5.0.0-alpha.4 shares essentially nothing with it — the dashboard/widget/layout-override model is gone, replaced by resources, builders, and an HTTP API. Plan a parallel install and a rewrite of your admin surface, not a composer update. Do not attempt this on the branch that runs your production admin.
Alpha means the API can change, and alpha.4 proves it. This release changed behaviour that alpha.1 shipped: the mediatheque file field stopped being createOnly(), the search route appeared on by default, and route parameters changed how they're read. Nothing here is hypothetical. Pin the exact version, pin the matching @panneau/* JS packages (alpha.4 ⇄ 5.0.0-alpha.5), and treat every alpha bump as a potentially breaking change: read the "Upgrading from" section between tags, run the conformance kit, and exercise every resource definition. Do not let an alpha float in a lock file you only update casually.
Requirements are a hard gate. PHP 8.3+ and Laravel 12 or 13. If your app is on Laravel 11 or PHP 8.2, you cannot evaluate this release at all — check before you spend a day on it. Media replacement adds a second gate: folklore/laravel-mediatheque 2.0.0-alpha.5 or later.
Missing features are missing, not "coming soon in a patch." No auth flows (password reset, 2FA, passkeys, OAuth), no export/import actions, no queued runs, no roles module, no pages or menus in the definition, no laravel-folklore plugin. Media handling lives in the separate laravel-mediatheque package, not in core. If your admin needs any of these, budget for building it yourself or wait for the betas. Don't design a plan that assumes roles land next month.
Policy discovery is the first thing that will bite you. Policies are required; if the package can't resolve one for a resource it will fail in a way that looks like a routing or config problem. Confirm how resource-to-policy mapping works in this version and register explicitly in a service provider if auto-discovery is ambiguous. A 403 on /definition is almost always this. A 403 or empty result set on /api/search can be the same thing — search filters by "can list," so a missing viewAny looks like a search bug.
ResourceQuery rejecting unknown filters and sorts is a feature — verify it fails loudly. You want an undeclared ?filter[secret]=1 to be rejected, not silently ignored while returning everything. Write one test per resource asserting that an undeclared filter and an undeclared sort both fail, and that a declared filter actually narrows. This is the single highest-value test you can write against this package.
Search route is on by default — decide, don't drift. Set panneau.search.enabled to false if you're not using global search; don't leave an endpoint live because nobody noticed. If you are using it, check the panneau-search rate limiter configuration against your UI's keystroke volume, and confirm Scout-indexed models and repository-backed models return consistent shapes for the same resource.
Global search and query-based actions have the same failure mode. Both expand a user-supplied selection or query into rows. In both cases the only thing standing between the caller and someone else's data is policy applied at selection/search time. Test it with two users and two scopes; don't infer it from reading the source.
Slug uniqueness has locale and parent edges. unique() is per locale and optionally narrowed by a closure receiving (Builder $query, array $values). The $values array is the rest of the submitted form — so a narrowing on parent_id silently becomes "unique across everything" if the parent isn't submitted on that form. Test: same slug, different locale; same slug, different parent; same slug, same parent (must fail). And if your repository is not Eloquent, implement ChecksSlugs or the check endpoint will have nothing to say.
fields.check is a write-shaped call against read data. POST resources/{resource}/fields/{field}/check is easy to forget when you write custom route registration, and easy to expose too widely when you do. If you mount routes selectively with resourceRoutes($resources, only: [...]), verify the check route and the search route are where you expect them — route:list --name=panneau after install, again.
Validation rule translation has edges. Portable validation rules become Laravel rules — check the mapping for anything without a Laravel equivalent (unique, exists, nested arrays, conditional rules). Test those cases explicitly; a rule that silently drops is worse than one that errors. Note that Slug::unique() is a field-level check at save time, distinct from a unique validation rule — you now have two places uniqueness is enforced, and they can disagree.
ETag churn and stale tabs. Serialize deterministically (stable key order, no updated_at microsecond noise if you can avoid it), and handle 412 in the client. Test with two browser tabs editing the same record. Note that a media replacement reprocesses the original — make sure that doesn't churn the parent resource's ETag.
Prefixes with parameters need their middleware to agree. tenants/{tenant}/... works because the controllers read route parameters by name, but the tenant still has to be resolved and authorised before the Panneau controllers run. Verify with a request that crosses tenants — a scoping bug here is a data leak, not a cosmetic one.
Deployment caches still apply. Routes are opt-in, so make sure their registration is deterministic at boot — no closures that read request state, no config-driven route generation that route:cache will flatten. Run php artisan optimize locally before shipping. Partial mounting (resourceRoutes(..., only: [...])) is config-shaped; confirm what route:cache produces matches what you intended.
Debugging checklist. php artisan route:list --name=panneau for missing or duplicated routes (search, fields.check, uploads); curl -H 'Accept: application/json' the /definition endpoint and read the problem+json body when it fails; curl the search endpoint as a user with narrow list permissions; the conformance kit's diff against the OpenAPI document for shape mismatches; config('panneau') in tinker if the install command published anything you didn't expect — especially panneau.search.enabled; Telescope for the N+1s that an API-driven admin will reintroduce the moment a field pulls a relation, and that global search will multiply across resources.
Assess it as an internal dependency, and write down what you learn. There are still no blog posts, no Stack Overflow corpus, and the docs are new. Keep docs/panneau.md with your resource definitions, your policy mapping, your search scope, and the exact versions you validated against (PHP package, @panneau/*, laravel-mediatheque). If the alpha stalls, the protocol spec is your exit ramp — which is a materially better position than 4.x ever offered.
How can I help you explore Laravel packages today?