alwayscurious / laravel-project-devtool
Simplify your developer's startup time on new projects from existing repo.
Package info
github.com/AlwaysCuriousCo/laravel-project-devtool
pkg:composer/alwayscurious/laravel-project-devtool
Fund package maintenance!
Requires
- php: ^8.3
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
This package is auto-updated.
Last update: 2026-06-03 20:43:05 UTC
README
Simplify your developers' startup time on new projects from an existing repo — one command to a clean-slate dev environment, and a clean way for your app to hook into it.
php artisan project:dev --setup tears the local environment down and rebuilds
it from scratch: clear caches → migrate:fresh → seed → build assets. Onboard a
new teammate, recover from a broken branch, or reset between feature spikes in a
single command instead of a wiki page of steps.
The part that makes it worth installing instead of writing your own shell
script: at every stage of the reset it fires a lifecycle event, and your app
attaches its own work by dropping a listener into app/Listeners. Generate
permissions, seed demo data, print login credentials — without ever editing
this package. The engine is mechanism; your app supplies the policy.
⚠️ Dev-only.
--setuprunsmigrate:fresh, which drops every table. Install it as a--devdependency and never point it at data you care about.
Install
composer require --dev alwayscurious/laravel-project-devtool
That's the whole setup — the command auto-registers. Publish the config only if you want to change defaults:
php artisan vendor:publish --tag="project-devtool-config"
Try it:
php artisan project:dev # prints available actions, does nothing destructive php artisan project:dev --setup # the full reset, with a confirmation prompt
The command
project:dev
{--setup : Fresh reset — rebuild DB, seed, and build assets}
{--new : With --setup, also install dependencies (composer + npm install)}
{--force : With --setup, skip confirmation prompts}
{--dry-run : With --setup, simulate the run without making any changes}
{--only= : With --setup, run ONLY these steps (comma-separated)}
{--skip= : With --setup, skip these steps (comma-separated)}
{--force-production : Allow --setup to run when the app environment is production}
| You want to… | Run |
|---|---|
| See what's available (safe, no-op) | php artisan project:dev |
| Reset the environment | php artisan project:dev --setup |
| Reset a freshly cloned repo, deps and all | php artisan project:dev --setup --new |
| Reset unattended (CI, scripts) | php artisan project:dev --setup --force |
| Preview exactly what would happen, change nothing | php artisan project:dev --setup --dry-run |
| Just reseed (no wipe, no asset build) | php artisan project:dev --setup --only=seed |
| Everything except the slow asset build | php artisan project:dev --setup --skip=build |
Before wiping anything, --setup confirms with a prompt that names the exact
connection and database it's about to drop — so a wrong .env can't surprise
you. --force skips it. And it refuses to run in production unless you
pass --force-production.
Run only the steps you need
--setup is no longer all-or-nothing. The selectable steps, in order, are
install, caches, migrate, seed, build. Use --only to run a subset or
--skip to drop a few:
php artisan project:dev --setup --only=migrate,seed # rebuild + reseed, skip caches/build php artisan project:dev --setup --skip=build # everything but the asset build
The confirmation prompt only appears when a destructive step (migrate or
seed) is actually in the run — --only=caches won't ask.
Preview with --dry-run
--dry-run walks the entire sequence, prints what each step would do, fires
every lifecycle event (so your listeners can announce their intent too), and
changes nothing — no migrations, no seeding, no processes, no prompt:
DRY RUN — simulating; no changes will be made.
[dry-run] would run: optimize:clear
[dry-run] would run: migrate:fresh
[dry-run] would run: db:seed
[dry-run] would run: asset build
✔ Dry run complete — no changes were made.
Timing report
Every real run prints a per-step timing table at the end, so you can see where onboarding time actually goes:
------------------ ---------
Step Time
------------------ ---------
optimize:clear 0.04s
migrate:fresh 2.11s
db:seed 0.83s
asset build 14.29s
total 17.27s
------------------ ---------
How --setup runs (and where you plug in)
The sequence is fixed and ordering-sensitive — your listeners can rely on it:
┌─ SetupStarting ← guard point; a listener may veto here (AbortSetup)
│ (wipe confirmation)
├─ optimize:clear → CachesCleared
├─ migrate:fresh → DatabaseMigrated ← the gap before seeding
├─ db:seed → DatabaseSeeded
├─ asset build → AssetsBuilding (fired just before the build)
└─ SetupCompleted ← end-of-run report point
Each event carries the running command, so a listener gets the live console and can run nested artisan commands:
$event->command->info('…'); // same output stream $event->command->option('new'); // read invocation flags $event->command->call('some:command', ['--force' => true]); // nested artisan $event->dryRun; // true during --dry-run
Why seeding comes after a separate DatabaseMigrated event
This is the design detail the package exists for. Some apps must generate
data (permissions, lookup tables) after the schema exists but before the
seeder runs — because the seeder consumes it (e.g. granting permissions to a
role). So the reset deliberately fires DatabaseMigrated in the gap between
migrate:fresh and db:seed, giving you a hook at exactly that moment. Merge
the two and the seam disappears; keep them split and your app slots right in.
Integrating your app: write a hook
A hook is just a listener that type-hints one of the lifecycle events. Laravel's event discovery wires it up — no registration, no config, no service provider edits.
Scaffold one
php artisan make:dev-hook BuildDemoData --event=DatabaseSeeded
Generates app/Listeners/BuildDemoData.php, pre-typed to the event and
documented with the full event list. --event is validated and defaults to
DatabaseSeeded.
Or write it by hand
namespace App\Listeners; use AlwaysCurious\LaravelProjectDevtool\Events\DatabaseSeeded; class BuildDemoData { public function handle(DatabaseSeeded $event): void { // Respect a simulation: announce intent, change nothing. if ($event->dryRun) { $event->command->line(' [dry-run] would seed demo content'); return; } $event->command->info('Seeding demo content…'); $event->command->call('db:seed', [ '--class' => \Database\Seeders\DemoSeeder::class, '--force' => true, ]); } }
Lifecycle reference
| Event | Fires… | Reach for it to… |
|---|---|---|
SetupStarting |
before the wipe confirmation; may throw AbortSetup |
guard the run (env present? right branch?) |
CachesCleared |
after optimize:clear |
prep that needs a clean cache/config |
DatabaseMigrated |
after migrate:fresh, before seeding |
generate permissions / data the seeder needs |
DatabaseSeeded |
after db:seed |
demo / sample data |
AssetsBuilding |
just before the asset build | prepare build inputs |
SetupCompleted |
after the build, before the command returns | print login URLs, credentials, next steps |
Hooks can't break the reset
A thrown exception from any listener is reported and swallowed so a broken custom hook never leaves you with a half-built database. The single exception is the deliberate veto below.
Guarding the reset: AbortSetup
Veto a doomed run before anything destructive happens by throwing AbortSetup
from a SetupStarting listener:
namespace App\Listeners; use AlwaysCurious\LaravelProjectDevtool\Events\AbortSetup; use AlwaysCurious\LaravelProjectDevtool\Events\SetupStarting; class EnsureSuperAdminPassword { public function handle(SetupStarting $event): void { if (empty(env('SUPER_ADMIN_PASSWORD'))) { throw new AbortSetup('SUPER_ADMIN_PASSWORD is not set — refusing to reset.'); } } }
The command prints the message, says “Nothing was changed.”, and exits with a
failure code — the database is never touched. AbortSetup is honoured only
from the SetupStarting pre-flight point; thrown later it's treated as a
misplaced veto (warned and swallowed) so it can't corrupt a half-built database.
Batteries (opt-in recipes)
Common integrations ship as listeners that are off by default — a project that doesn't use them pulls in zero coupling — and self-guard when an optional dependency is missing.
Filament Shield permissions
GenerateShieldPermissions regenerates
filament-shield permissions on
DatabaseMigrated, in the gap before seeding, so your seeder can grant them to a
super-admin role. If filament-shield isn't installed it skips with a notice
instead of failing. Enable it in config:
'recipes' => [ 'shield' => [ 'enabled' => true, 'panel' => 'admin', ], ],
Configuration
config/project-devtool.php — everything you'd want to vary, nothing hardcoded:
return [ // Seeder class run during --setup (null = framework default DatabaseSeeder). 'seeder' => null, // Asset build command (argv array). Set to null/[] to skip the build step. 'build' => ['npm', 'run', 'build'], // Dependency install commands used by --new. 'install' => [ 'composer' => ['composer', 'install'], 'npm' => ['npm', 'install'], ], // Opt-in recipes — off by default. 'recipes' => [ 'shield' => ['enabled' => false, 'panel' => 'admin'], ], ];
build→null/[]skips the asset build (the command tells you it did).seeder→ a class name to seed something other than the defaultDatabaseSeeder.install→ swap inyarn,pnpm,bun, etc.
Requirements
- PHP
^8.3 - Laravel 11, 12, or 13
Testing
composer test
License
The MIT License (MIT). See License File.