Artisan Commands

This document is the command reference for the starter kit. Architectural notes for DDD live separately in ddd.md.

End-User Commands

Command Purpose
php artisan sk:doctor Run environment health checks and report any issues
php artisan sk:install Install the starter kit into the project
php artisan sk:update Update installed kit files safely
php artisan sk:upgrade Upgrade an older starter-kit/Laravel line to the current one
php artisan sk:publish Publish optional components, language files, or config
php artisan sk:eject Eject a vendor-resident domain into the app for full customization
php artisan make:sk-domain Generate a new domain scaffold
php artisan remove:sk-domain Remove a generated domain
php artisan env:sync Sync .env keys into .env.example
php artisan env:sync --reverse Check whether .env is missing keys from .env.example
php artisan site:install Reset and reinstall site data for local/dev usage
php artisan sk:seed-permissions --fresh Rebuild role and permission data from config
php artisan postman:sync Push the api-dock OpenAPI document to Postman
php artisan apidog:sync Push the api-dock OpenAPI document to Apidog
php artisan api-dock:agent-guide Install the API Dock authoring rules into agent instruction files
php artisan api-dock:sync Regenerate, compare, and store the API Dock OpenAPI snapshot
php artisan api-dock:diff Compare the generated OpenAPI document with the stored snapshot
php artisan api-dock:export Export API Dock artifacts for AI tools and OpenAPI consumers
php artisan sk:redact-activity-secrets Irreversibly remove credentials from existing activity logs
php artisan file-manager:purge-trash Permanently delete old File Manager trash
php artisan encryption:key Generate a dedicated DATA_ENCRYPTION_KEY, preserving the old key
php artisan encryption:rekey Re-encrypt settings and 2FA secrets onto the primary data-encryption key
php artisan encryption:health Report which key each encrypted value needs (read-only)

sk:doctor

Runs a series of environment health checks and reports the result of each.

php artisan sk:doctor
php artisan sk:doctor --json
php artisan sk:doctor --only=database-connection,redis-connection
php artisan sk:doctor --only=timezone-storage
  • --json outputs machine-readable JSON instead of a table
  • --only=<selectors> runs a comma-separated subset of checks. A selector is the check's stable id — its class name without the Check suffix, hyphenated and lowercased (DatabaseConnectionCheckdatabase-connection) — so it does NOT change with the active locale even though the displayed check name is now translated. The three selectors in the table that differ from their class name (filemanager-disk, permission-matrix, unresolved-routes) keep working as aliases

Checks (name → --only selector):

Check --only selector
PHP Extensions php-extensions
Node Version node-version
Database Connection database-connection
Redis Connection redis-connection
Passport Keys passport-keys
Storage Symlink storage-symlink
Writable Directories writable-directories
Log Channel log-channel
Log Stack log-stack
Queue Driver queue-driver
Queue Worker queue-worker
Schedule Configured schedule-configured
Mail Driver mail-driver
NPM Build Artifacts npm-build-artifacts
Config Cache config-cache
FileManager Disk filemanager-disk
Theme Manifest theme-manifest
Timezone Storage timezone-storage
Activity Log Secrets activity-log-secrets
Permission Matrix permission-matrix
Unresolved Routes unresolved-routes
Data Encryption Key data-encryption-key
Missing Kit Dependencies missing-kit-dependencies

ActivityLogSecretsCheck returns FAIL when an activity_log row still contains a password hash, token, or secret. That happens when the package was updated (which closes the leak for new rows immediately) without running php artisan migrate, so the historical rows were never cleaned. Back up the database and run php artisan migrate or php artisan sk:redact-activity-secrets; removal is irreversible. A missing activity-log table or a table without JSON payload columns is OK, an undecodable JSON payload is WARN, and a database error is WARN rather than a pass.

The check is a bounded, read-only probe, not the full cleanup pass. It reads the first 500 rows ordered by primary key — the same fixed cost on MySQL, MariaDB, SQLite, and PostgreSQL alike — and decides in PHP rather than in SQL, so a differently-cased key such as Password is caught regardless of the JSON column's collation. The messages state exactly what was measured: over a larger table a finding is reported as a floor ("at least N"), and a clean result names the window it covered instead of clearing the whole table. Run php artisan sk:redact-activity-secrets --dry-run --all for the exhaustive count — --all is required, because without it the command uses a SQL key-name prefilter on MySQL, MariaDB and SQLite that a differently-cased key can slip past.

PermissionResourcesDriftCheck returns WARN when config/permission-resources.php no longer covers every resource and ability the package ships — for example an installation that predates the FileManager files.create / files.update / files.delete split and still declares the old set. That file is in the updater's never-touch list, deliberately, because it is where you declare your OWN resources; the price is that new package entries never arrive on their own, and the symptom is a 403 on a screen that used to work. The check is one-directional: resources you added yourself are never reported. Fix by adding the listed entries to config/permission-resources.php and running php artisan sk:seed-permissions. A missing or empty config is WARN, and an unreadable package copy is WARN rather than a pass.

TimezoneStorageCheck returns FAIL when config('app.timezone') is not exactly UTC. When that setting is correct, it also reads SELECT @@session.time_zone from the default connection. For a MySQL/MariaDB connection, only +00:00 and UTC pass; SYSTEM and every other value FAIL because TIMESTAMP rows can be offset on disk even while the application reads them back consistently. A query failure or missing result returns WARN, never a pass. Other database drivers report OK with the session check marked inapplicable. Keep display configuration separate through APP_DISPLAY_TIMEZONE, and see Timezones for the connection contract and existing-data conversion guide.

UnresolvedRouteCheck returns FAIL for every route that carries the kit's check.permission middleware without any permission being derivable from it — no <resource>.<action> name the ability map recognises, no explicit check.permission:<perm> argument, and no entry in the exempt list. Such a route reaches its controller unauthorized. By default the middleware lets it through and logs a throttled warning, and no release changes that for an existing install. Setting STARTER_KIT_ALLOW_UNRESOLVED_ROUTES=false (config starter-kit.permissions.allow_unresolved) turns every listed route into a 403 — that is the opt-in, and a newly installed project already ships with it. The check reports FAIL regardless of that setting, deliberately — its job is to show what the flip will deny, not what the current configuration allows. Routes the package ships resolve on their own through a route-name map inside CheckResourcePermission, so what this check lists is your own routes plus any kit route you renamed. Fix each one by renaming it to a mapped <resource>.<action>, gating it with an explicit permission argument, or — when it is deliberately permission-free — declaring it under starter-kit.permissions.unrestricted_routes. See UPGRADE.md for the ordered path.

DataEncryptionKeyCheck reads config only — no table scan, no decryption — and never returns FAIL. No dedicated key configured (DATA_ENCRYPTION_KEY empty) is WARN: sensitive settings and 2FA secrets are still encrypted with APP_KEY, and a php artisan key:generate on a server migration will make them unreadable. A dedicated key with a non-empty DATA_ENCRYPTION_PREVIOUS_KEYS is WARN: rotation is unfinished. A dedicated key with an empty previous-key list is OK. See Data Encryption and encryption:key / encryption:rekey / encryption:health below.

MissingKitDependenciesCheck returns FAIL when a Composer package the kit's own require block lists is not installed in the consumer app (composer.lock drifted from a newer kit release, or a package was removed by hand). Without it, the kit code that depends on the package fails at runtime with an opaque "class not found" instead of a clear diagnosis at the source. Fix by running the reported composer update lvntr/laravel-starter-kit -W, or let sk:update offer to run it for you (see below). No missing packages is OK.

Exit codes:

Code Meaning
0 All checks passed
1 At least one check returned WARN
2 At least one check returned FAIL

sk:install

Use this on first setup.

php artisan sk:install
php artisan sk:install --force
php artisan sk:install --adopt
php artisan sk:install --adopt --dry-run
php artisan sk:install --no-interaction
php artisan sk:install --without-ai-skill
php artisan sk:install --without-eject
php artisan sk:install --resume
php artisan sk:install --modules=telescope,pulse
  • --force overwrites existing publishable files, and bypasses the already-installed safety stop described below. It is the opt-out for every preservation rule: a consumer-edited published file and a file the hash registry has no record of are both overwritten
  • --adopt rebuilds storage/starter-kit/hashes.json from the shipped stubs for an app that is already installed but lost its registry (a stateless deploy, a cleared storage/). It copies no file, runs no migration and never touches .env; combine with --dry-run to preview the registry it would write
  • --dry-run prints what would be written and exits without writing anything
  • --no-interaction accepts all defaults automatically; useful for CI or scripted installs
  • --without-ai-skill skips publishing the Lvntr Starter Kit AI skills entirely — both the Claude Code copies (.claude/skills/) and their Codex mirror (.codex/skills/). Useful when the consumer uses neither Claude Code nor Codex with the kit's skill bundle
  • --without-eject skips the default User and Role domain eject on a first install; the runtime stays in vendor and resolves via class_alias. Omit this flag to have app/Domain/User/ and app/Domain/Role/ created automatically. See install.md for the ownership trade-off.
  • --resume resumes a previously interrupted install, skipping steps already checkpointed as completed. See install.md for the full resume workflow.
  • --modules= selects optional observability recipes to composer require and wire up (telescope, pulse, horizon, sentry; comma-separated or repeatable). Left empty, you are prompted interactively in a TTY and skipped entirely under --no-interaction or a non-interactive session. An unrecognized key fails fast before anything is written. See Optional Observability Recipes in install.md for the full recipe table and behavior.

It is a first-install command, not a repair tool. Before the banner is printed, a fail-closed detection pass looks for kit schema tables and install-only paths; if it finds any without a matching hash registry, the command stops before writing a single byte and points at sk:update, sk:publish --tag=<area> or --adopt. An existing .env is never overwritten, on a first install or a re-run: missing .env.example keys are appended and first-install-only keys are seeded only where absent, and no existing value is ever rewritten.

Exit codes. A failed mandatory step (publish, migrations, seeders, permission seeding, Passport keys, encryption keys) aborts the run, leaves the checkpoint pending for --resume, skips the hash-registry write and exits non-zero. Frontend steps (npm install, Wayfinder generation, npm run build, composer dump-autoload, cache clears) stay non-fatal on purpose — they warn, print the command to run by hand, and are listed again in the closing summary.

The migration step asks how to proceed when the database already holds tables. The default (and the only option a non-interactive session ever gets) is Run pending migrations only; Skip migrations is always offered. The destructive Drop all tables and run fresh migrations entry is withheld entirely when APP_ENV looks production-like, APP_DEBUG is off, the session cannot prompt, or any existing table already holds rows — and when it is offered, choosing it requires typing the database name (or the word fresh) at a text prompt. Anything else, an empty answer included, falls back to the additive migrate path with nothing dropped.

The config phase idempotently adds 'timezone' => '+00:00' to existing mysql and mariadb arrays in config/database.php. It preserves an existing value, skips a missing connection, and does not touch other drivers. On a re-run against a database that already holds data on a non-UTC session, the step is skipped and points at sk:upgrade instead. See install.md and Timezones.

sk:update

Use this after composer update.

php artisan sk:update
php artisan sk:update --dry-run
php artisan sk:update --force
php artisan sk:update --without-ai-skill
  • --without-ai-skill skips regenerating the .codex/skills/ AI-skill mirror for this run. (An install-time --without-ai-skill opt-out is honored automatically — skipped skills are never re-added.)

It also reports kit-required Composer packages you don't have. If the kit's require block lists a package composer.lock predates, sk:update prints it (the same list sk:doctor's missing-kit-dependencies check flags) and, in an interactive TTY, offers to run composer update lvntr/laravel-starter-kit -W for you — declining, or answering the prompt's default, only prints the command instead. --dry-run, --no-interaction, and any non-interactive session (CI, piped stdin) always skip the prompt and just print the command. A failed composer update here is reported but does not fail sk:update itself.

Your edits survive, including in package-owned files. Every copied file is compared against the hash recorded for it at install/update time, and a file whose content no longer matches that record is preserved and listed under "Skipped". This now covers app/Enums/PermissionEnum.php too: it is package-owned and refreshed on every update, but it is also a backed enum with public for() / allFor() helpers, so a project ability added to it (case Approve = 'approve';) is preserved instead of silently overwritten. A preserved copy is reported separately, because the package does expect its own cases to exist — diff your file against the same relative path under vendor/lvntr/laravel-starter-kit/stubs/, merge the new cases, or re-run with --force to take the package version and discard your edits. A copy with no hash record (an installation predating hash tracking) is offered in the same interactive prompt as every other untracked file rather than assumed to be untouched.

sk:upgrade

Use this when moving between major starter-kit/Laravel lines, such as Laravel 12 -> 13.

The command also runs idempotent AST config steps for existing installs: a legacy 'display_timezone' => env('APP_TIMEZONE', ...) entry in config/app.php is rewritten to read APP_DISPLAY_TIMEZONE, and missing UTC timezone entries are added to existing MySQL/MariaDB arrays in config/database.php without overwriting consumer values.

If the default MySQL/MariaDB session is not UTC and the users table holds data, the command warns and asks for explicit consent before pinning the connection. Declining, an inspection failure, or an unattended run without --force (--no-interaction or non-TTY) skips the edit and reports how to apply it later. --force bypasses this consent gate. The command never converts stored rows; follow the one-time conversion guide first. Re-running the upgrade does not duplicate the config entries.

php artisan sk:upgrade
php artisan sk:upgrade --force
php artisan sk:upgrade --skip-build

sk:publish

Use this only when you want project-owned copies of package assets.

php artisan sk:publish
php artisan sk:publish --tag=components
php artisan sk:publish --tag=datatable
php artisan sk:publish --tag=form
php artisan sk:publish --tag=tabs
php artisan sk:publish --tag=skeleton
php artisan sk:publish --tag=ui
php artisan sk:publish --tag=filemanager
php artisan sk:publish --tag=composables
php artisan sk:publish --tag=plugins
php artisan sk:publish --tag=lang
php artisan sk:publish --tag=config
php artisan sk:publish --tag=helpers

sk:eject

Use this when you need to fully customize a domain whose runtime currently runs from the vendor package. Ejecting copies the domain's backend classes into app/Domain/{Name}/, rewrites their namespaces to App\Domain\{Name}\, refreshes the domain's Vue pages, and wires any event/listener bindings into app/Providers/DomainServiceProvider.php so the audit log keeps firing. Run --dry-run first to preview what will change.

Unless --force, --dry-run, or --no-interaction is passed, the command asks for confirmation before doing any work — ejecting is a one-way trade-off (the domain stops receiving kit runtime updates via composer update). sk:install's own internal default-domain eject always passes --force, so the fresh-install flow is not interrupted by this prompt.

php artisan sk:eject User
php artisan sk:eject User --dry-run
php artisan sk:eject User --force
php artisan sk:eject User --no-vue
php artisan sk:eject Role --destination=/tmp/eject-preview
php artisan sk:eject ApiClient          # controllers + requests + resources (ApiClient + ApiToken)
php artisan sk:eject ContentLanguage    # domain + controller + request + resource
  • --dry-run prints the copy/rewrite/injection plan without writing any files. Always run this first.
  • --force overwrites files that already exist — both the backend app/Domain/{Name}/ tree and the domain's Vue pages. Without --force, eject never overwrites an existing file: an already-present app/Domain/{Name}/ makes the command exit early, and any Vue page that already exists is left untouched and reported as preserved — only missing pages are written. This protects edits you made to pages shipped by sk:install.
  • --no-vue skips refreshing the domain's Vue pages; only the backend classes are ejected.
  • --destination=<path> redirects output to an arbitrary directory instead of the app root. Intended for isolated testing.
  • --skip-autoload skips the composer dump-autoload call at the end of eject. Use this only when the calling process (such as sk:install) will run composer dump-autoload itself afterwards. Without this flag, eject always regenerates autoload and exits non-zero if regeneration fails.

Exit code: if Composer's autoload regeneration fails (e.g. composer is missing or errors out), the command prints the error and exits non-zero even though the files were copied — so CI and scripts do not mistake a broken autoload for a successful eject. Run composer dump-autoload manually, then re-verify.

Ejectable domains

Fourteen domains can be ejected. Domains not in this list are already app-owned and do not need ejecting.

Domain Backend classes Vue pages HTTP layer ejected Event bindings injected
User yes yes 3 (Created/Updated/Deleted)
Role yes yes 3 (Created/Updated/Deleted)
Setting yes yes controller + requests
Logs yes yes controller + requests 1 (FilesDeleted)
ActivityLog yes yes controller
ApiClient yes ApiClient + ApiToken controllers + requests + resources
ApiRoute yes yes controller
ContentLanguage yes controller + requests + resource
SystemHealth no (controller-only) controller
Definitions no (controller-only) API + Service controllers
MediaUpload no (controller-only) controller
Files no (Vue only) yes
Session yes
Media yes

ApiClient ejects the API-token flow too: the ApiClient domain owns both the OAuth client and the personal-access-token actions, so sk:eject ApiClient copies the ApiClientController and the ApiTokenController (plus their FormRequests and API Resources, and rewrites both api-client-route.php and api-token-route.php imports). The one-time Passport client-secret reveal stays byte-identical — eject moves the file, it does not change behavior.

SystemHealth, Definitions, and MediaUpload are controller-only: they have no app/Domain/{Name} backend tree. SystemHealth drives Artisan + Gate directly from its controller; Definitions ejects both the Api\DefinitionController and the Service\DefinitionServiceController (which wrap the vendor DefinitionService — that service stays vendor); MediaUpload ejects the Api\MediaUploadController whose media.destroy route lives in the shared routes/web.php. None ship a FormRequest or app/Domain folder, so no autoload-affecting class is added unless a controller is copied.

Models stay app-owned — eject never relocates a Model. App\Models\{ContentLanguage,Media,Definition,...} remain published in your app and are never aliased to vendor (an App\Models\X alias would break Laravel's XPolicy discovery and route-model binding). The vendor controllers/domains reference these models by their App\ FQCN, and an ejected app/Domain/ContentLanguage keeps that App\Models\ContentLanguage reference unchanged.

Why Auth and Helpers are not ejectable: Auth screens are already 100% app-owned — sk:update keeps them fresh without any eject. The sk-helpers.php global helpers ship as a single overridable file; consumers delete what they do not need.

Files is Vue-only: the FileManager backend (controller, FormRequests, route-registry infrastructure) stays vendor-managed after ejecting Files. Only the admin Vue pages (resources/js/pages/Admin/Files/) are copied into your app so the UI can be customised while the backend continues to receive kit updates. To revert, delete the copied resources/js/pages/Admin/Files/ directory — the vendor pages take over via app.ts fallback.

What the namespace rewrite covers

Only the ejected domain's own namespace is rewritten. Every other vendor reference is left untouched:

  • Lvntr\StarterKit\Domain\User\Actions\CreateUserActionApp\Domain\User\Actions\CreateUserAction
  • use Lvntr\StarterKit\Domain\Shared\Actions\BaseAction;unchanged (Shared base classes stay in vendor)
  • Lvntr\StarterKit\Http\Responses\ApiResponseunchanged
  • Any other domain not being ejected — unchanged

Update-loss trade-off

Warning: after ejecting a domain, future composer update runs that include security fixes or bug fixes to that domain's vendor runtime will not apply to your copy. You own the files — you must apply upstream changes manually.

sk:update never touches backend files in app/Domain/ (they are not hash-tracked stubs). Vue pages ejected with --force follow the normal hash-tracking rules: if you edit them, sk:update marks them as customized and skips them.

Reverting an eject (v1: manual)

A --revert flag is planned for a future version. To revert manually:

  1. Delete app/Domain/{Name}/.
  2. Remove the Event::listen(...) lines for that domain from app/Providers/DomainServiceProvider.php.
  3. Run composer dump-autoload.

The class_alias entries in StarterKitServiceProvider will resume resolving App\Domain\{Name}\* imports back to the vendor copies automatically.

make:sk-domain

Creates a new domain with the starter kit structure.

# Bare domain (backward compatible)
php artisan make:sk-domain Article

# Namespaced
php artisan make:sk-domain Store/Product

# Core options
php artisan make:sk-domain Product --admin --api --events --fields="name:string,price:decimal"
php artisan make:sk-domain Product --from-migration=2026_03_21_create_products_table.php

# Opt-in extras — individual flags
php artisan make:sk-domain Article --with-policy --with-factory

# Opt-in extras — bulk syntax
php artisan make:sk-domain Article --with=policy,factory,test

# Relation scaffold
php artisan make:sk-domain Article --with-relations --relations="belongsTo:User,hasMany:Comment"

# Full
php artisan make:sk-domain Article --with=policy,factory,seeder,test,relations --relations="belongsTo:User,morphTo:commentable"

Core flags:

Flag What it does
--fields="name:string,age:integer" Comma-separated field:type pairs. Available types: string, integer, bigInteger, unsignedBigInteger, float, decimal, boolean, text, longText, json, date, dateTime, timestamp. Omit to be prompted field-by-field.
--id-type=id|uuid|ulid Primary key strategy. id (default) is an auto-increment bigint; uuid/ulid add the matching HasUuids/HasUlids concern and switch the migration's id column. Prompts interactively when omitted — skipped entirely with --from-migration (detected from the file).
--api / --no-api Force-generate or force-skip the API controller + routes. Prompts (default: yes) when neither is passed.
--admin / --no-admin Force-generate or force-skip the Admin controller + routes. Prompts (default: yes) when neither is passed.
--events / --no-events Force-generate or force-skip the Created/Updated/Deleted events and their logging listeners. Prompts (default: yes) when neither is passed.
--soft-deletes / --no-soft-deletes Force-enable or force-disable SoftDeletes on the model and migration. Prompts (default: yes) when neither is passed — skipped entirely with --from-migration (detected from the file).
--vue=none|empty|full Vue page generation mode; only applies when the Admin layer is generated (forced to none otherwise). full scaffolds Index (DataTable) + Create/Edit (FormBuilder); empty scaffolds an empty Index page only; none skips Vue generation. Prompts interactively (default: full) when omitted.
--vue-fields / --no-vue-fields Only relevant with --vue=full. Include the model's fields in the generated DataTable columns and FormBuilder, or generate an id-only skeleton. Prompts (default: yes) when neither is passed and fields exist.
--from-migration=<filename> Parse fields, ID type, and soft-deletes from an existing migration file instead of --fields/--id-type/prompts, e.g. --from-migration=2026_03_21_create_products_table.php. Accepts a full or partial filename (glob-matched under database/migrations/).

Opt-in flags (v2):

Flag What it generates
--with-policy Policy class
--with-factory Factory
--with-seeder Seeder
--with-test Feature test
--with-permissions Registers the resource (all abilities) in config/permission-resources.php, plus an EN display name — TR label and role assignment are left for you to fill in
--with-relations Relation scaffold (use together with --relations)
--with=<policy,factory,seeder,test,permissions,relations> Bulk syntax — any combination of the opt-ins above in a single flag; individual --with-* flags are additive on top of it
--relations="belongsTo:User,hasMany:Comment,morphTo:commentable" Relation definitions for the scaffold. Supported types: belongsTo, hasMany, morphTo. Passing --relations= implies --with-relations

Use it when you want the package conventions for actions, DTOs, queries, requests, routes, and Vue screens.

remove:sk-domain

Removes a generated domain and its related files.

php artisan remove:sk-domain Product
php artisan remove:sk-domain Product --force
  • --force skips the confirmation prompt

env:sync

Keeps .env.example aligned with the project .env keys.

php artisan env:sync
php artisan env:sync --reverse

--reverse is a safe validation mode: it does not write files, it only reports keys that exist in .env.example but are missing from .env.

site:install

Useful in local development when you want a clean installation flow again.

php artisan site:install

The command shows the target environment and database before confirmation, only allows local and setup, and hard-blocks environments that look like production.

Since v13.4.1 the pipeline runs passport:client --personal --provider=users between passport:keys and the default admin seed, so a fresh install leaves you with a working personal-access-token path without any manual follow-up.

postman:sync

Pushes the api-dock-generated OpenAPI document to Postman so the workspace collection stays in sync with the current API surface.

php artisan postman:sync

Reads the postman settings group: postman.api_key and postman.workspace_id are required, and postman.collection_id is rewritten with the upstream id after a successful push. The command fails early with a clear error when the key or workspace id is missing — set them under Settings → API Clients → Postman in the admin panel (or insert the rows directly) and re-run. Internally it delegates to Lvntr\StarterKit\Domain\ApiRoute\Actions\SyncPostmanAction, which uses the shared OpenApiExporter helper to resolve api-dock's DocumentGenerator and hands the document to Postman unchanged. The Action imports the fresh collection first, persists the new UID, then best-effort deletes the old one — a failed push never leaves the workspace without a working collection.

apidog:sync

Pushes the same api-dock OpenAPI document to Apidog for teams that mirror the collection there.

php artisan apidog:sync

Reads the apidog settings group: apidog.access_token and apidog.project_id are required. If either value is missing the command aborts with a "not configured" error — populate them under Settings → API Clients → Apidog (or insert the rows directly) and re-run. The heavy lifting is done by Lvntr\StarterKit\Domain\ApiRoute\Actions\SyncApidogAction, which shares the OpenApiExporter helper with postman:sync — the document is uploaded to Apidog unchanged so the pushed project mirrors the real server contract.

api-dock:agent-guide

Installs api-dock's authoring rules into this project's agent instruction files, so a coding agent working on API endpoints follows the same documentation conventions the panel expects.

php artisan api-dock:agent-guide
php artisan api-dock:agent-guide --file=AGENTS.md
php artisan api-dock:agent-guide --print
  • --file= (repeatable) targets a specific instruction file relative to the project root. Without it, the command writes to whichever of AGENTS.md, CLAUDE.md, GEMINI.md already exist in the project — AGENTS.md is the cross-vendor convention, the other two are vendor-specific and are only written when the project already keeps one.
  • --print writes the instruction block to output instead of to a file.

api-dock:sync

Regenerates the OpenAPI document, compares it against the stored snapshot, and writes the new snapshot to config('api-dock.snapshot.path') (default storage/api-dock/openapi.json).

php artisan api-dock:sync
php artisan api-dock:sync --check
  • --check exits with code 1 on breaking changes and does not write the snapshot — use this in CI to fail a build on an accidental breaking API change.

api-dock:diff

Compares the currently generated OpenAPI document with the stored snapshot without writing anything.

php artisan api-dock:diff
php artisan api-dock:diff --json
  • --json emits the structured diff as JSON instead of the human-readable summary.

api-dock:export

Exports API Dock artifacts for AI tools and OpenAPI consumers, built through the same DocumentGenerator the /api-dock panel uses.

php artisan api-dock:export --openapi
php artisan api-dock:export --mcp --llms
php artisan api-dock:export --openapi --output=storage/app/api-exports
  • --openapi writes the generated OpenAPI document (openapi.json)
  • --mcp writes MCP tool definitions
  • --llms writes the llms.txt bundle
  • --output= overrides the export directory (default config('api-dock.ai.export_path'), i.e. storage/api-dock)

This is the command the admin panel's Regenerate Docs button runs under the hood (api-dock:export --openapi) via Lvntr\StarterKit\Domain\ApiRoute\Actions\RegenerateApiDocsAction.

sk:redact-activity-secrets

Recursively removes sensitive keys from both the attribute_changes and properties JSON columns of existing activity-log rows while preserving all other keys. The operation is irreversible: take a database backup before running it.

php artisan sk:redact-activity-secrets --dry-run
php artisan sk:redact-activity-secrets
php artisan sk:redact-activity-secrets --chunk=500
php artisan sk:redact-activity-secrets --all
Flag Purpose
--dry-run Report rows that would be redacted without writing changes
--chunk=<rows> Process this many rows per round trip (default 500, maximum 5000)
--all Scan every row instead of using the sensitive-key prefilter

The command is idempotent and should be re-run after restoring an older backup. If a JSON payload cannot be decoded, it is counted, reported with a warning, and left unchanged; inspect that row manually because it may still contain a credential.

encryption:key

Generates a dedicated DATA_ENCRYPTION_KEY, preserving the current primary key in DATA_ENCRYPTION_PREVIOUS_KEYS. See Data Encryption for the full adoption and rotation walkthrough.

php artisan encryption:key
php artisan encryption:key --show
php artisan encryption:key --force
Flag Purpose
--show Print a freshly generated key and write nothing to .env
--force Run even when the environment looks like production

A default run: (1) resolves the current primary key (DATA_ENCRYPTION_KEY, or APP_KEY on first adoption); (2) generates a new random key; (3) prepends the old primary to DATA_ENCRYPTION_PREVIOUS_KEYS; (4) only then writes the new DATA_ENCRYPTION_KEY. APP_KEY is never touched, on any path. The command refuses to run under a production environment without --force. After it finishes, run encryption:rekey, then encryption:health, and clear DATA_ENCRYPTION_PREVIOUS_KEYS only after health reports OK.

encryption:rekey

Re-encrypts settings and 2FA secrets onto the primary data-encryption key. Read the server migration runbook before running this during a maintenance window.

php artisan encryption:rekey
php artisan encryption:rekey --dry-run
php artisan encryption:rekey --only=settings
php artisan encryption:rekey --chunk=500
Flag Purpose
--dry-run Perform every decrypt attempt and print the summary without writing a single byte
--only=<surface> Limit the run to settings or two-factor (comma-separated to combine)
--chunk=<rows> Rows read, locked and rewritten per round trip (default 200, maximum 2000)

Each row is tried against every key in the resolution chain, in order. The first key that decrypts it re-encrypts and writes back with the primary key; a row already on the primary key is skipped with no write. A row that decrypts with no key is left byte-for-byte untouched, counted, and listed by identifier (settings.key / users.id) in the summary — it is never nulled, deleted, or overwritten.

encryption:health

Reports which key each encrypted value needs and whether DATA_ENCRYPTION_PREVIOUS_KEYS can be cleared. Read-only — no key material is ever printed.

php artisan encryption:health
php artisan encryption:health --json
  • --json emits a machine-readable report mirroring sk:doctor --json's shape

Verdicts: everything on the primary key and nothing undecryptable → "Safe to clear DATA_ENCRYPTION_PREVIOUS_KEYS" (exit 0); any row still on a previous key → "Run encryption:rekey first; do NOT clear DATA_ENCRYPTION_PREVIOUS_KEYS"; any undecryptable row → the loudest failure, naming the affected rows and the missing key. A surface that could not be fully scanned (missing table, query error) only ever downgrades the verdict, never upgrades it.

file-manager:purge-trash

Permanently deletes soft-deleted File Manager items older than the configured age.

php artisan file-manager:purge-trash
php artisan file-manager:purge-trash --days=30
php artisan file-manager:purge-trash --chunk=1000

The default is 7 days. The command only targets File Manager media (collection_name = files) and trashed folders; avatar, logo, editor upload and other MediaLibrary collections are not touched. The shipped routes/console.php schedules it daily, with withoutOverlapping().

  • --chunk=<n> (default 500, range 1–5000) rows are loaded per round trip and walked with chunkById, so the whole trash is never held in memory.
  • The run takes a cache lock (starter-kit:file-manager:purge-trash, 1 h TTL) so two schedulers — or an operator racing the cron — cannot hand the same rows to two forceDelete() calls. A second concurrent run warns and exits 0 without purging.
  • One failing item does not stop the run; the remaining items are still processed and the command exits non-zero when anything was left behind.