dragon-code/laravel-migration-actions
Laravel Migration Actions lets you attach custom action classes to migrations and run them automatically around schema changes, so data setup, cleanup, and other migration-related tasks stay organized alongside the migrations that need them.
The package gives you a migration-like ledger for arbitrary one-off work — backfills, seeding reference data, creating an admin user, toggling a feature flag, moving files — with the same "run once, tracked in a table, reversible" semantics as php artisan migrate. The repo you'll land on is a monorepo (TheDragonCode/laravel-deploy-operations) that also ships the dragon-code/laravel-migration-actions package; the README is the only documentation, so read it once end-to-end before writing your first action.
Minimal steps:
composer require dragon-code/laravel-migration-actions
php artisan vendor:publish --provider="DragonCode\LaravelActions\ServiceProvider"
php artisan migrate # creates the tracking table (often `actions` / `migration_actions`)
First things to open after install, in this order:
config/actions.php (or config/deploy-operations.php depending on the version) — paths for the action directory, the table name, connection, the before/after split, and (since 7.3) the queue block: connection, queue name, job class, and uniqueness lock duration.database/actions/ — this tells you the exact class/method shape for your installed version, which matters more than any blog post.php artisan list | grep -i action — the command names and flags (--isolated, --queue, --force) have shifted across majors; confirm the ones you actually have.First real use case to try: seed a lookup table in production without writing a throwaway SQL script.
// database/actions/2024_05_01_120000_seed_currencies.php
use DragonCode\LaravelActions\Action;
return new class extends Action {
public function up(): void
{
DB::table('currencies')->insertOrIgnore([
['code' => 'USD', 'name' => 'US Dollar'],
['code' => 'EUR', 'name' => 'Euro'],
]);
}
public function down(): void
{
DB::table('currencies')->whereIn('code', ['USD', 'EUR'])->delete();
}
};
Run it, then run it again — nothing should happen the second time. That "it just doesn't re-run" property is the whole value proposition. Then read the queue section of the config: as of 7.3 actions can be dispatched to a worker instead of executed inline, and that changes everything about how you sequence a deploy.
Deployment hooks around migrations. The package's differentiator is ordering against migrate. Actions placed in the before path run prior to the migration batch, actions in the after path run once migrations finish — the service provider hooks Laravel's migration events, so a plain php artisan migrate in your deploy script does the right thing. Use this to break chicken-and-egg deploys: add a nullable column (after), backfill it (after), then in a later release add the NOT NULL constraint. Use before for things that must exist before a migration touches them, e.g. creating an enum-ish lookup table a migration's FOREIGN KEY will reference.
Inline vs queued execution. Long-running actions (a 2M-row backfill, an S3 sweep) block the deploy when run inline. Since 7.x you can push them onto a queue: set queue.connection / queue.queue in the config and dispatch the action instead of running it in-process. The trade-off is real — a queued action is not finished when php artisan migrate returns, so your deploy script can no longer assume the data operation completed. Choose inline for anything the next migration depends on, queued for everything else, and never mix the two within one ordering phase.
Custom job class via queue.job. 7.3.0 lets you point the dispatcher at your own job class instead of the package default. This is the extension point for cross-cutting queue concerns the package shouldn't own: $tries, $backoff, $timeout, middleware() for rate limiting, tenant context, or failed() handlers that page someone. Extend the package's base job rather than reimplementing it, or you'll lose the ledger write that makes the action "run once."
Uniqueness lock duration. The new queue uniqueness lock (7.3.0) prevents two workers from picking up the same operation concurrently — which is exactly the duplicate-execution hazard you get from a multi-node deploy or a retry storm. Size the lock to exceed your worst-case job runtime, not the average. A 5-minute lock on a 40-minute backfill means worker B acquires the lock at minute five and replays the whole thing.
--isolated behaves like core Laravel. 7.3.0 fixed the --isolated flag to match Laravel's presence semantics (an atomic cache lock; the flag only takes effect when present). Use it as the belt-and-braces guard when the same action command could be invoked from more than one place — a deploy hook and a cron, say. It needs a cache store that supports atomic locks (redis, database, memcached, dynamodb); the array driver is useless here and file is dicey on shared storage.
Data operations, not schema operations. Keep migrations/ for DDL and actions/ for DML and side effects. The discipline pays off: schema migrations should be replayable on a fresh DB in tests, whereas actions often touch external systems (S3, a payment provider, an email send) and must never fire in the test suite.
Idempotency even though it "runs once." The ledger only protects you if the row is written — and with queued actions the gap between "dispatched" and "row written" is wider, so a worker crash can leave an operation dispatched-but-unrecorded. Write up() defensively (insertOrIgnore, updateOrCreate, if (!Schema::hasColumn(...))) so a re-run after a manual ledger cleanup is safe.
Environment scoping. Most configurations allow enabling/disabling per environment. In local and testing, disable the package entirely or point it at an in-memory tracking table — otherwise php artisan migrate:fresh starts executing production data operations on your laptop. If you enable queueing, also make sure queue.default isn't pointing at a real broker in testing; set it to sync or null and Queue::fake() before touching migrate.
Deploy script shape.
php artisan down --retry=60 || true
git pull
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan up
If your version requires an explicit invocation rather than event hooks, add php artisan deploy:operations --force immediately after migrate --force. If you've enabled queued actions, add a worker check (php artisan queue:monitor default:1000) or a post-deploy smoke assertion — otherwise up returns to users while the backfill is still half done. Never run actions on a per-server basis in a multi-node deploy — run them from a single deploy job, or you'll get duplicate work and race conditions.
Rollback as a first-class citizen. Implement down() for anything reversible and test it, because --rollback / --step against the actions table is how you recover from a bad deploy. For irreversible operations (sent emails, deleted rows), make down() a documented no-op and log loudly instead of faking a reversal. Note that a queued action's down() has the same dispatch lag as its up().
Integration with existing seeders. Use a seeder for data every environment needs on a fresh DB; use an action for data that must appear exactly once in an existing database at a specific point in time. Don't convert seeds into actions — you lose the migrate:fresh / db:seed workflow that tests rely on.
CI/CD guards. Add a smoke step in the pipeline that runs actions against a disposable database (with the package enabled, queue driver sync) to catch syntax errors and permission mistakes before they hit production. An action that references a column dropped in the same deploy is a two-minute bug in CI and a 2 a.m. incident in production.
Old actions rot against new models. An action from eighteen months ago that calls User::create([...]) will break the moment you remove a $fillable entry or add a non-nullable column. Treat actions like migrations: prefer DB::table() query-builder calls, hard-code column names, and never import models or services that are likely to evolve. Migrations survive because they're frozen SQL; actions survive only if you freeze them too.
The tracking table must already exist. The first deploy of a fresh install will fail if actions run before the package's own migration. Publish and migrate first, ship actions second.
Ordering is filename-lexicographic, not dependency-aware. Use full timestamps (2024_05_01_120000_...) like migrations. Two actions in the same second are a real hazard in a monorepo where two people merge simultaneously.
Queued actions reorder themselves. Inline actions run in filename order, deterministically, inside the deploy. Dispatched ones land on a queue and execute as workers free up — so action B can finish before action A. If B reads what A writes, don't queue them; if you must, put both in the same job chain or make B poll. This is the single most likely way the 7.3 queue features will bite you.
The uniqueness lock expires silently. 7.3.0's lock duration is a lease, not a mutex. When it lapses, another worker takes it — no warning, no exception, just a second execution. Set it generously, and make up() idempotent anyway so an expired lease costs you time rather than correctness.
Transactions and DDL don't mix. If your version wraps actions in a transaction by default, MySQL will still silently commit on DDL. Don't assume up() is atomic across a Schema:: call plus a DB::table() write; either split them or set the atomic flag off and do your own compensation.
--force and prompt suppression in production. Laravel's confirmation prompts will hang a non-interactive deploy job until it times out. Every artisan call in a deploy script needs --force (or --no-interaction).
Config caching bites. Publish the config, then php artisan config:cache. If you change the actions path, table name, queue.job class, or lock duration later, remember to re-cache; a stale cached config is the most common "why did my action not run" answer — and now also the most common "why did it run inline instead of queueing."
Debugging checklist, in order: does the row exist in the tracking table (SELECT * FROM actions ORDER BY id DESC)? Is there a job sitting in failed_jobs or still pending on your queue? Is the file in the configured directory, not just database/actions/? Does the filename match the expected pattern? Is the class/return-anonymous-class form correct for your major version? Does -v on the artisan command surface the exception? Nine times out of ten it's the directory path, a cached config, or a worker that was never restarted after a deploy.
Restart workers on deploy. Queued actions execute your old code until php artisan queue:restart runs. A deploy that adds a new action class and queues it before the restart will fail with "class not found" or, worse, run yesterday's logic.
Rollback hygiene. Rolling back a release rolls back all the actions in that batch, including ones you were happy with. Keep batches small, and prefer a new forward-fixing action over a rollback in production — same advice as migrations.
Extension points worth knowing: a custom action stub (publish and edit so make-style scaffolding matches your house style), the configurable connection/table (useful for pointing at a shared ledger across apps), the queue.job class override (7.3.0) for retries/middleware/alerting, the before/after path keys if you'd rather organize by domain than by phase, and events around action execution, if your version exposes them — the clean hook for "alert Slack on any production action."
Overall verdict. A few hundred stars and zero dependents tells you this is a small, single-maintainer utility — read the source (it's a handful of files) before betting a critical deploy pipeline on it, and pin the major version. The 7.3 queue additions show it's still actively growing, which is good, but they also move it from "a tiny thing that runs SQL" toward "a tiny thing that runs SQL somewhere, later" — more moving parts, more failure modes, more reasons to understand the internals. Where it fits, it replaces a genuinely unpleasant habit: ad-hoc php artisan tinker sessions and "ask DBA to run this SQL" tickets that no one can audit or reproduce. Where it doesn't fit — anything you'd want re-applied on every deploy — use a seeder or an idempotent command instead.
How can I help you explore Laravel packages today?