ezappslab/laravel-evolver
Laravel Evolver by ezappslab gives Laravel teams a structured, version-aware way to manage evolutionary application and database changes, keeping deployments repeatable, auditable, and rollback-friendly without adding workflow overhead.
Evolver is a deployment engine for Laravel: you write small "action" classes, and evolver:deploy runs them in a planned, ordered, transactional batch while recording a checksummed history in your database.
Prerequisites are strict — PHP 8.4, Laravel 12/13, Symfony 7/8 — so confirm those before installing. Then:
composer require infinity/evolverphp artisan vendor:publish --tag=evolver-config and (on a fresh install only) republish the migration. If you are upgrading from v2, keep your existing evolutions table — v3 uses the same columns and SHA-256 checksums, so history stays valid.actions.path at your actions directory (defaults to database/evolver/actions).php artisan evolver:action backfill_product_slugs.An action is now anything extending Infinity\Evolver\Deployment\Action, with an up() method and an optional down() for rollback:
use Infinity\Evolver\Deployment\Action;
class BackfillProductSlugs extends Action
{
public function up(): void
{
Product::query()->chunkById(500, fn ($products) =>
$products->each->updateSlug()
);
}
public function down(): void
{
// reverse, or omit entirely if this action is irreversible
}
}
First use case to reach for: the one-off data migration / backfill that you don't want in a standard Laravel migration because it touches rows, external calls, or needs to be idempotent and re-runnable. Second: a guarded "deploy-time" step that must run exactly once per environment.
Check state with php artisan evolver:status and dry-run with php artisan evolver:deploy --pretend before touching production. In production, deploys prompt for confirmation unless --force is passed.
Version-windowed actions. Actions can declare expiresAt() (renamed from requiredUntil()) to bound when they apply — lower bound inclusive, upper bound exclusive. This is how you mark an action as "only run for app versions < 2.0" so it stops applying once you roll past that version. Note: with the default none resolver, every action applies regardless of the window it declares (windows are still validated for correctness). To make windows meaningful, set versioning.application.resolver to config, file, json, git, or a custom class implementing Contracts\ApplicationVersionResolver.
public function expiresAt(): ?string
{
return '2.0'; // applies only while the application version is < 2.0
}
Rollback-driven deploys. Since every action can now have a down(), model risky actions as reversible pairs: create a table in up(), drop it in down(). The evolver:rollback command reverses the latest batch (or a specific one via --batch=<uuid>) in reverse order. This is the single biggest behavioral shift from v2 — you can now treat a deploy as a unit you can back out of. It also means you should think about reversibility when writing each action; actions without down() are treated as irreversible and will fail rollback clearly rather than silently.
Batch semantics. transactions.mode values are now action, batch, or none (was per_action / entire_run / none). Use batch when your actions must succeed or fail together (e.g. a set of related backfills); use action when they should each commit independently so a failure doesn't strand completed work. History reads and writes happen on the configured DB connection inside the same transaction as the action (EVOLVER_DB_CONNECTION), so cross-database setups work correctly.
Locked, deterministic deploys. Planning and execution run under one atomic cache lock, so two deploys can't collide. Configure with lock.store and lock.seconds (env: EVOLVER_LOCK_STORE, EVOLVER_LOCK_SECONDS). In CI, this means a hung deploy blocks the next one until the lock expires — set the TTL thoughtfully, and be ready to clear the lock manually if a job dies mid-flight.
Checksum safety workflow. Every action file is checksummed during planning but only loaded when it actually runs. This has three practical consequences:
safety.fail_on_changed_action is on. Do not edit executed action files — add a new action instead.This makes the actions directory effectively append-only, which is the right mental model: actions are historical records, not normal source files.
API versioning via middleware. If you version an API, versioning.api.resolver picks the strategy. The new header resolver reads X-API-Version (configurable via versioning.api.resolvers.header.name), rewrites the request internally to the versioned route, and returns 400 on a missing header / 404 on an unsupported version. The alternative is url with versioning.api.resolvers.url.base_path. Register routes with the new macro:
Route::apiVersion('v2', function () {
Route::get('/users', [UserController::class, 'index']);
});
ApiVersionContext holds the resolved version per request and resets each request, so this is Octane-safe.
Testing. Evolver::fake([...]) swaps in an in-memory action source with a scenario DSL (add, touch, forget) and assertions (assertExecuted, assertNotExecuted, assertRolledBack, assertNothingDeployed). Use this in feature tests instead of running real deploys. Since the git resolver goes through Laravel's Process facade, you can Process::fake() it too.
Facade access. Evolver::applicationVersion() and Evolver::apiVersion($request = null) read resolved versions anywhere in app code or tests.
versioning.strategy → versioning.application.resolver, and versioning.{file,config,json,git} → versioning.application.resolvers.{...}. The versioning.git.strip_prefix → versioning.application.resolvers.git.prefixes change is easy to miss (it's now a list, default ['v', 'V']).evolver:action now generates snake_case filenames, so backfill-product-slugs becomes ..._backfill_product_slugs.php. Renaming an executed action file is equivalent to deleting it — it becomes missing and blocks deploys. Also note evolver:action never overwrites existing files and validates names against directory escape, so you can't accidentally clobber or path-traverse.none resolver still validates windows. Even with versioning "off" (the default), malformed expiresAt() values will error. Don't assume none means "no version logic at all."v prefix is ignored, pre-releases sort before releases, short versions like 1.10 compare numerically. If you have a custom resolver, match these semantics so status output is trustworthy.ApiVersionRouteRegistrar::routes() is gone. Replace with Route::apiVersion('v2', $routes, $middleware = ['api']). The middleware is registered globally and only acts under the API base path — verify your base path config lines up or the middleware will no-op.down() is optional, but evolver:rollback over a batch containing an irreversible action fails clearly. Design batches deliberately so irreversible steps aren't mixed with reversible ones you might need to unwind.ActionExecutionException reports the failing action plus the actions that completed before it — log the full payload, not just the message, when debugging a partial deploy. Also watch for LockException (contended deploy), RepositoryUnavailableException (DB unreachable), and MissingApiVersionException.Contracts\ActionRepository for a custom action source, or Contracts\ApplicationVersionResolver for a custom application-version source; both are the sanctioned replacement for the removed EvolutionRepository and VersionResolver contracts.Api\*, Deploy\*, Version\*, Models\Evolution, and the unused evolver_effects migration are gone. Their replacements live under Deployment\*, Versioning\*, Routing\*, plus the two contracts above.evolver:api-status is removed, along with API version lifecycle states. Use evolver:status (now shows pending / executed / changed / missing / not-applicable, plus window and batch) and Evolver::apiVersion() for runtime inspection.composer lint. Mirror that discipline in your action classes — typed returns, no loose mocks — and the checksum/deploy engine will be far easier to reason about.How can I help you explore Laravel packages today?