ssbityukov/filament-command-center
Filament plugin for running pre-approved Artisan and shell commands from your panel. Commands come only from an allow-list defined in config, database, or code — never free-form input. Supports per-command gates, queued runs with live output, run history, and CI validation.
Install and register the plugin. This is a Filament 5 / Laravel 12–13 / PHP 8.3+ package, Linux and macOS only.
composer require ssbityukov/filament-command-center
php artisan vendor:publish --tag=command-center-config
use Bityukov\CommandCenter\Filament\CommandCenterPlugin;
public function panel(Panel $panel): Panel
{
return $panel->plugin(CommandCenterPlugin::make());
}
Define the three gates before you look at the UI. The most common first-hour confusion is an empty panel: all three abilities are undefined by default, and Laravel denies an undefined gate, so nothing renders and nothing errors loudly.
// AppServiceProvider::boot()
Gate::define('command-center:access', fn ($user) => $user->is_admin);
Gate::define('command-center:manage-commands', fn ($user) => $user->is_admin);
Gate::define('command-center:prune-history', fn ($user) => $user->is_admin);
Run php artisan command-center:check immediately. It warns when the access gate is missing, and fails on unknown tokens, undefined abilities, invalid timeouts and shell commands with shell mode off. Add it to CI on day one — it is the fastest feedback loop this package offers, and it is the honest answer to "why is the page blank".
First use case: the ops shortcut panel. The shipped starter set (cache clears, queue:restart, migrate:status, about, plus confirmation-gated migrate/down/up) is already a defensible v1. Read it in the published config as the reference for every feature before writing your own entries — it demonstrates groups, timeouts, confirm, and ability-gating in one file.
Decide two things up front, because retrofitting them is annoying:
cache (default) needs no migration but is capped, TTL-bounded, and erased by cache:clear — i.e. by one of the shipped commands. If the audit trail matters, publish command-center-migrations and set 'history' => ['driver' => 'database'] before anyone relies on the history page.Treat config/command-center.php as code, not configuration. Every entry is a reviewed, deployable capability. This is the pattern the package is optimised for:
'commands' => [
'route-list' => [
'run' => 'route:list --path={path}',
'label' => 'List routes',
'group' => 'Diagnostics',
'timeout' => 30,
'variables' => [
'path' => ['type' => 'text', 'label' => 'Path filter', 'rules' => ['string', 'max:255']],
],
'flags' => ['--json' => ['label' => 'As JSON']],
],
'backup-db' => [
'run' => 'backup:run',
'ability' => 'run-backups',
'queue' => true,
'timeout' => 600,
],
],
Each token becomes exactly one argv element. The template is split on whitespace before substitution, so the only sane way to handle user-supplied paths, ids or names is as a variable — never by interpolating into run.
Layer a per-command ability over the module access gate. access gets an admin into the catalogue; ability decides which entries exist for them. Gated commands are removed from the catalogue payload entirely, so support staff never see buttons they can't press, and the run action re-checks server-side anyway. Use this to carve out a "safe ops" panel for on-call engineers separate from a "dangerous maintenance" panel for release managers.
Prefer model variables over raw text for anything tenant-scoped. A model variable re-resolves the submitted id through its own scoped query, so a scoped select cannot reach another tenant's record. That is a real security property you would otherwise have to hand-roll validation for. Configure model, title_attribute and value_attribute, and pair it with a scoped query.
Use select + flags for the common "one command, many shapes" case rather than duplicating entries. --json as a flag and a bounded text path filter covers a surprising share of diagnostic commands.
Queue anything over a few seconds, and emit progress from your own commands. Synchronous runs are for fast, deterministic commands. For long ones:
'backup-db' => ['run' => 'backup:run', 'queue' => true, 'timeout' => 600],
$this->line('##CC_PROGRESS:'.$percent.'##');
Nothing is inferred from log volume — a chatty command without the sentinel shows an indeterminate bar. If you own the command, adding one line buys a materially better operator experience.
Use 'redact' => true for anything token-shaped, and 'confirm' => '…' for anything that changes the running application. The confirm pattern is the cheapest guardrail in the package and the shipped migrate/down/up entries model it.
Publish migrations only when you need them. Two independent reasons: the database history driver, and the DatabaseSource. Both read from the published runs and commands tables. If you enable DatabaseSource, add it explicitly:
'sources' => [
\Bityukov\CommandCenter\Sources\ConfigSource::class,
\Bityukov\CommandCenter\Sources\DatabaseSource::class,
],
…and note that command-center:check fails if the source is on while command-center:manage-commands is undefined. That failure is a feature: it stops you shipping a panel where an unauthenticated-role can edit the allow-list.
Override access per panel when one panel needs a different rule than the global gate:
CommandCenterPlugin::make()->authorize(fn ($user) => $user->can('ops'));
Schedule pruning as a standing task once you are on the database driver: php artisan command-center:prune --days=30 behind the prune-history ability.
Write custom sources only when config genuinely can't express your case (e.g. commands generated from a tenant registry). The Source layer is the documented extension point, and it keeps the allow-list property intact where a free-form runner would not.
An undefined gate is a silent blank page, not an error page. access, manage-commands and prune-history all deny until you define them. If the module "doesn't appear", run command-center:check first; only then start debugging the panel. Setting abilities.access to null opens it to everyone who can reach the panel — a deliberate decision, because then each command's own ability is the only thing left.
The cache history driver is erased by one of your own commands. cache:clear wipes the audit trail. It is fine for a dev/staging convenience panel; it is not an audit trail. Also capped and TTL-bounded regardless.
redact hides a value from history, not from the OS. A secret passed as argv may be visible in ps to other users on the host. Pass secrets via environment variables; there is no in-package fix for this.
Cancellation only works for queued runs, and only between output chunks. For synchronous runs the flag-setting request and the process-running request are different PHP processes, so Cancel barely functions. A silent command cannot be interrupted at all — its timeout is the real bound. Do not promise operators an interrupt button on sync entries.
A literal path with a space cannot go in a run template — the template splits on whitespace before substitution. Always pass such values as variables, which are substituted as a single argument afterwards.
A token may not start with - unless the variable opts in, and may not occupy the command position. This is a design guard against value-injection becoming option-injection or binary substitution. If you need a value beginning with a dash, that is a deliberate opt-in — don't fight it, and never "solve" it by moving the token earlier in the template.
Blank optional variables remove their whole argv element. --path={path} disappears rather than running as --path=. Good: no accidental empty-string filters. Surprising if you expected the flag to survive.
Run templates are executed as argv arrays via Process; there is no shell. Process::fromShellCommandline() appears nowhere in src/ and a CI guard enforces it. Any attempt to compose shell pipelines in config will simply not work — one run entry is one executable plus arguments. Chain via a wrapper Artisan command you own instead.
Shell mode being on does not unlock arbitrary commands. Definitions remain allow-listed and still execute as argv vectors. Enabling it buys you less than it sounds, and it broadens what command-center:check will accept — turn it on only if a specific allow-listed binary needs it.
Windows is untested and not claimed, for a real reason. On Unix, the argument array execs the binary with no shell; on Windows, Symfony always converts it to an escaped command string. That is a different security guarantee, and this package has never tested it. Do not adopt on Windows servers.
A command that fails to start may not throw. Symfony can fall back to a shell wrapper, so the run is recorded as failed with exit code 126 or 127 and the diagnostic in the output, not the error field. When triaging "it failed instantly", read the output body before the exception/log.
Queued runs reload the actor through the panel's auth guard (fixed in 1.0.2; command-center.auth_guard is a fallback). If your panel uses a custom authGuard, verify the actor resolution — a mismatch doesn't just fail to find the user, it can find a different real person with the same id and ask the gate about them. Runs queued pre-1.0.2 and picked up after upgrade read as "no guard" and are denied, which is the safe failure mode.
The database editor cannot edit variables yet. Commands with variables must be defined in config. If your operator workflow needs ad-hoc variables, keep those entries in config rather than in the DB.
Anyone who can edit the allow-list can run anything the PHP process can. That means config write access, and it means the commands table. Restrict manage-commands accordingly and treat the DB source table as deploy access. The package controls which commands run and with what arguments — it cannot sandbox what a command does once running.
Migrations are published, not run. Two separate publishes (command-center-migrations for tables, command-center-views for markup) and an existing file is skipped unless --force. Publishing without migrating produces a "nowhere to write" failure in the editor and an empty history, not a clear error.
Debugging checklist, in order: command-center:check → confirm gates are defined → confirm the per-command ability is defined → confirm migrations ran if using DB source or DB history → check the run's recorded exit code and output body before the log. That sequence resolves nearly every reported "it doesn't work" case with this package.
How can I help you explore Laravel packages today?