Panel Cache in Production
php artisan panel:cache runs discovery once, at deploy time, and writes the class names each panel owns to bootstrap/cache/panels.php. With that file in place discovery never runs again: no filesystem scan, no reflection, nothing per request. Reach for this page when wiring the command into a deploy, when a release directory or a symlink is involved, or when a resource is missing from a production panel and nothing is logged.
A minimal working example
composer install --no-dev --optimize-autoloader
php artisan panel:cache2
INFO Panels cached: 2 panels, 1 resources, 5 pages, 4 widgets.Or, since the command is registered as an optimize hook, get it for free:
php artisan optimize # config, routes, events, views — and panel:cache
php artisan optimize:clear # and panel:clear2
The hook is registered under the key panels, which is also how it is skipped:
php artisan optimize --except=panels
php artisan optimize:clear --except=panels2
Where it goes in the pipeline
composer install --no-dev --prefer-dist --optimize-autoloader # 1
php artisan migrate --force # 2
npm ci && npm run build # 3
php artisan optimize # 4 ← here
php artisan queue:restart # 52
3
4
5
After Composer, because discovery resolves file paths through Composer's PSR-4 prefix map. A manifest written against the previous autoloader names classes that are no longer where it says they are, and the symptom is a panel missing whatever moved.
Before the workers restart, because a worker resolves panels from the same manifest the web process does.
What lands on disk
use PandaPanel\Cache\PanelManifest;
PanelManifest::path(); // '/var/www/releases/42/bootstrap/cache/panels.php'2
3
<?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 ( /* ... */ ),
),
),
'fingerprint' => '…',
);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Class names only, var_exported rather than serialized, so the file is a plain return [...] that opcache can hold and a person can read. The list per panel is explicit registration plus discovery, deduplicated and sorted, so two runs on two machines produce a byte-identical file.
The path is resolved through the application's 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.
The write is atomic
$temporary = self::path().'.'.getmypid().'.tmp';
File::put($temporary, $this->render([...]));
File::move($temporary, self::path());2
3
4
Rendered to panels.php.{pid}.tmp and then moved into place. A request that arrives mid-deploy either reads the old manifest or the new one, never half of either, and two processes caching at the same time cannot interleave.
That makes panel:cache safe to run against a live release directory. It is not a reason to: see the release-directory notes below.
The API behind the command
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, writes atomically, returns what it wrote |
clear | clear(): bool | deletes the file and forgets the in-memory copy |
warnIfStale | warnIfStale(PanelRegistry $registry): void | development only |
use PandaPanel\Cache\PanelManifest;
use PandaPanel\Core\PanelManager;
use PandaPanel\Core\PanelRegistry;
$manifest = app(PanelManifest::class);
$manifest->exists(); // bool
$manifest->for(app(PanelManager::class)->get('admin')); // the three lists
$manifest->write(app(PanelRegistry::class)); // rebuild from code
$manifest->clear(); // bool: true unless the delete failed2
3
4
5
6
7
8
9
10
A health check that has to answer "did this release cache its panels" is one line:
use PandaPanel\Cache\PanelManifest;
abort_unless(app(PanelManifest::class)->exists(), 503, 'Panels are not cached.');2
3
for() is the seam every panel goes through during registration. A panel in the file is served from it; a panel that is not — one registered by a test, one added after the cache was written — falls back to discovery for itself alone. There is no all-or-nothing failure mode here, which is why a stale manifest is quiet rather than loud.
Release directories and symlinks
Deploy tools that build a new release directory and flip a symlink work correctly, with one condition: bootstrap/cache must belong to the release, not to the shared directory.
| Directory | Shared between releases? |
|---|---|
storage | yes, as usual |
.env | yes, as usual |
bootstrap/cache | no |
Sharing it is the failure this whole page exists to prevent. The manifest describes the classes in one release; shared, a rollback serves the newest manifest against the previous release's code, and every class added in the release you just rolled back from is a class name in a file that no longer exists. Discovery is not going to run and tell you — the manifest is the authority.
Cache inside the new release, before the symlink flips:
cd /var/www/releases/42
php artisan optimize
ln -sfn /var/www/releases/42 /var/www/current2
3
Opcache
The manifest is PHP source. On a server with opcache and opcache.validate_timestamps=0, the previous release's compiled copy survives the deploy until opcache is reset — which is true of config.php and routes-v7.php too, and is why a deploy on such a server already resets it.
Nothing panel-specific is needed. If the application resets opcache after a deploy, the manifest is covered by the same reset.
Octane and other long-lived processes
PanelManifest is a singleton and caches its loaded copy in memory. A worker that has already read the file keeps serving it until the worker is recycled.
php artisan octane:reload
php artisan queue:restart2
clear() only forgets the in-memory copy for the instance it was called on, so running panel:clear from the CLI does not reach a running Octane worker. Deploys restart workers, which they already do for every other reason. See Octane.
The staleness warning does not run in production
if (! app()->hasDebugModeEnabled() && ! app()->environment('local', 'testing')) {
return;
}2
3
warnIfStale() compares a fingerprint — the count of PHP files under each discovery path and the newest modification time among them — against the one recorded in the manifest. In local, testing, or with debug mode on, a mismatch logs:
[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
Production gets nothing, deliberately: there the manifest is the authority, nothing should touch the filesystem, and the check would cost a stat per PHP file under every discovery path on every boot. Turning APP_DEBUG on in production turns that cost on with it.
The consequence is worth stating plainly. A production deploy that fails to run panel:cache produces no warning at all. It produces a panel that is missing whatever the release added, with a 404 on its URL and no sidebar entry. Put the command in the deploy, not in a runbook.
Rebuilding without a full deploy
php artisan panel:clear
php artisan panel:cache2
Or the same thing through optimize. Both are idempotent: panel:clear treats a missing manifest as success, so it is safe on a machine that never cached.
Gotchas
- A shared
bootstrap/cachebreaks rollbacks. This is the single most expensive mistake on this page. - Nothing user-specific is ever cached. Authorization results, navigation active state, badge values and record data are recomputed per request, because caching them would serve one person's answers to everybody. There is no option to change that.
- The manifest is keyed by panel id. Renaming a panel invalidates its entry, and that panel falls back to discovery — a filesystem scan per request — until the cache is rebuilt.
- A malformed file is treated as empty.
load()requires the file and anything that is not an array becomes[], so discovery runs. Slower, correct, and silent. - Two manifest shapes load. A file written before the fingerprint existed is a flat map of panel id to classes and still boots, so an upgrade does not need a cache clear to start.
panel:cachedoes not cache routes. They are separate caches; runroute:cacheas well. See Route cache.- Do not commit
bootstrap/cache/panels.php. It is generated per release from the code in that release.
See also
- Production checklist
- Route cache, Config cache
- Rollbacks, Octane, Composer and autoloading
- Caching — the manifest, the fingerprint, and what is never cached
- Discovery
panel:cache,panel:clear- Panel routes 404