Caching
The panel cache is one file, bootstrap/cache/panels.php, holding the class names each panel owns. With it in place discovery never runs: no filesystem scan, no reflection, nothing per request. Nothing else is cached — every answer that depends on the user or the URL is computed per request, on purpose. Reach for this page before a deploy, or when a class you added has vanished from a panel.
The two commands
php artisan panel:cache
php artisan panel:clear2
INFO Panels cached: 2 panels, 1 resources, 9 pages, 5 widgets.
INFO Panel manifest cleared.2
Both are registered as optimize hooks under the key panels, so a deploy that already runs optimize gets them:
php artisan optimize # includes panel:cache
php artisan optimize:clear # includes panel:clear2
| Command | Signature | Description |
|---|---|---|
panel:cache | panel:cache | Discover panel resources, pages, and widgets, and cache the manifest |
panel:clear | panel:clear | Remove the cached panel manifest |
panel:clear is idempotent: a missing manifest is success, not an error, so optimize:clear on a fresh checkout does not fail.
What the file looks like
<?php
// Generated by "php artisan panel:cache". Do not edit.
return array (
'panels' =>
array (
'admin' =>
array (
'resources' =>
array (
0 => 'App\\Panels\\Admin\\Resources\\Users\\UserResource',
),
'pages' => array ( /* ... */ ),
'widgets' => array ( /* ... */ ),
),
'app' => array ( /* ... */ ),
),
'fingerprint' => '…',
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var_export rather than serialization, so the file is a plain return [...] that opcache can hold and a human can read. Class names only — no closures, no resolved metadata, nothing user-specific. The test suite asserts that the rendered file contains neither Closure nor function.
The list per panel is explicit registration ∪ discovery, deduplicated and sorted, so two runs on two machines produce a byte-identical file.
PanelManifest
PandaPanel\Cache\PanelManifest is a container singleton and the only reader and writer of that file.
| Method | Signature | Notes |
|---|---|---|
path | static path(): string | app()->bootstrapPath('cache/panels.php') |
exists | exists(): bool | |
for | for(Panel $panel): array{resources: list<string>, pages: list<string>, widgets: list<string>} | Manifest when present, discovery otherwise |
write | write(PanelRegistry $registry): array | Builds for every registered panel and writes atomically |
clear | clear(): bool | Deletes the file and forgets the in-memory copy |
warnIfStale | warnIfStale(PanelRegistry $registry): void | Development only |
use PandaPanel\Cache\PanelManifest;
$manifest = app(PanelManifest::class);
$manifest->exists(); // bool
$manifest->for(panel('admin'))['resources'];// list<class-string>
PanelManifest::path(); // '/app/bootstrap/cache/panels.php'2
3
4
5
6
7
The path goes through bootstrapPath() rather than base_path('bootstrap/...'), because an application may move that directory — and a manifest written somewhere optimize:clear does not look is a stale cache nobody can clear.
write() renders to panels.php.{pid}.tmp and then moves it into place, so a half-written file can never be loaded, and two processes caching at once cannot interleave.
for() is the seam every panel goes through during registration:
public function for(Panel $panel): array
{
$cached = $this->load()[$panel->getId()] ?? null;
if ($cached !== null) {
return $cached;
}
return [
'resources' => $this->discoverer->resources($panel),
'pages' => $this->discoverer->pages($panel),
'widgets' => $this->discoverer->widgets($panel),
];
}2
3
4
5
6
7
8
9
10
11
12
13
14
A panel present in the file is served from it. A panel that is not — one registered by a test, or added since the cache was written — falls back to discovery for itself alone.
Writing one by hand
use App\Panels\Admin\Resources\Users\UserResource;
use PandaPanel\Cache\PanelManifest;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelRegistry;
$registry = new PanelRegistry;
$registry->register(
Panel::make('explicit')
->path('explicit')
->resources([UserResource::class]),
);
$manifest = app(PanelManifest::class)->write($registry);
$manifest['explicit']['resources']; // [UserResource::class]2
3
4
5
6
7
8
9
10
11
12
13
14
15
write() returns the same array it wrote, so a command or a test can assert on it without re-reading the file.
What is never cached
| Cached | Not cached |
|---|---|
| Resource class names, per panel | Authorization results |
| Page class names, per panel | Navigation active state |
| Widget class names, per panel | Badge values |
| The discovery fingerprint | Record data, table rows, widget data |
Those all depend on the current user or the current URL. Caching them would serve one person's answers to everybody, which is a security failure rather than a stale screen. SharePanelData builds every shared prop from a closure for the same reason, and NavigationBuilder recomputes visibility and active state per request.
The staleness warning
panel:cache writes a list of class names and discovery then never runs. That is the whole point of it, and it is also the trap: a resource added afterwards is not in the panel at all. No error, no empty state, no route — the sidebar looks exactly as it did before, and the answer is unguessable from the symptom.
PandaPanel\Cache\DiscoveryFingerprint exists to make that guessable.
use PandaPanel\Cache\DiscoveryFingerprint;
/** @param list<Panel> $panels */
public static function of(array $panels): string;
public static function isStale(array $panels, ?string $recorded): bool;2
3
4
5
The fingerprint is, for each discovery path of each panel, the count of PHP files under it and the newest modification time among them, hashed with xxh128. A path that is not a directory summarizes as missing, which is itself a change worth noticing — a renamed directory is a panel that silently discovers nothing. Paths are sorted, so two panels declaring the same paths in a different order do not read as a change.
warnIfStale() runs once at the end of provider boot, after every panel is registered. It does nothing at all unless a manifest exists and the environment is local, testing, or has debug mode on. When both hold and the fingerprint no longer matches:
[panel] The cached panel manifest is out of date: the classes under the
discovery paths have changed since `php artisan panel:cache` last ran. Until
you run `php artisan panel:clear`, anything added since then is invisible — no
route, no navigation entry, and no error to say so.2
3
4
Being unsure is never a reason to warn. isStale() returns false when there is no manifest, no fingerprint recorded in it, or a path that cannot be read.
Deploying
composer install --no-dev --optimize-autoloader
php artisan optimize # config, routes, events, views — and panels
npm ci && npm run build2
3
Cache after composer install, not before: discovery resolves file paths through Composer's PSR-4 map, and a manifest written against an old autoloader would name classes that are no longer where it says.
Roll back the same way. optimize:clear removes the manifest along with the rest; a deploy that only replaces code and leaves bootstrap/cache/panels.php from the previous release is the exact failure the fingerprint warns about — except that it warns in development only, and a production deploy gets no warning at all.
Gotchas
- A cached manifest in development is usually a mistake. Everything you add is invisible until you clear it. If you ran
optimizelocally, runoptimize:clear. panel:cachedoes not cache routes. They are separate; runroute:cacheas well. Panel routes are cacheable because every one points at a controller method rather than a closure.- Two manifest shapes are readable. A file written before the fingerprint existed is a flat map of panel id to classes, and it still loads — an upgrade does not need a cache clear to boot.
- The manifest is keyed by panel id. Renaming a panel invalidates its entry, and that panel falls back to discovery until the cache is rebuilt.
- A malformed file is treated as empty.
load()requires the file, and anything that is not an array becomes[]— discovery then runs, which is slower but correct. clear()only forgets the in-memory copy for that instance. Under Octane, a worker that already loaded the manifest keeps serving it until it is recycled. Deploys should restart workers, which they already do.- The fingerprint costs a
statper PHP file under the discovery paths, and only in development with a manifest present — which in development is the unusual case, so it normally costs nothing.