babenkoivan/elastic-migrations
Elastic Migrations for Laravel lets you version and share Elasticsearch index schemas across environments. Write migrations, run or roll them back, check status, and perform zero-downtime reindexing with familiar Artisan commands.
1. Install
composer require babenkoivan/elastic-migrations
The service provider is auto-discovered on Laravel 5.5+. There is nothing to register by hand.
2. Point it at a cluster
This package does not ship its own connection config — it resolves the Elasticsearch client from the rest of the babenkoivan/elastic* stack. Set the connection in .env before doing anything else:
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USER=
ELASTICSEARCH_PASSWORD=
Sanity check the connection in tinker before writing migrations:
app(\Elastic\Elasticsearch\Client::class)->info();
If that throws, fix it now — a failing migration later will just look like a broken migration.
3. Read the generated stub first
php artisan make:elastic-migration CreateProductsIndex
Open the file the command produces. That file is the canonical answer for your installed version — it tells you whether the package expects an anonymous class, which base class it extends, and how the client is exposed (facade vs. injected). Copy that shape; don't copy snippets from blog posts written against an older major.
Migrations live in database/elastic_migrations by default. Publish the config if you want to change the path or add an index prefix:
php artisan vendor:publish --provider="ElasticMigrations\ServiceProvider"
4. First real use case: bootstrap an index before Scout writes to it
The typical day-one task is "my Scout model needs an index with correct mappings." Write it as a migration:
use ElasticMigrations\Facades\Elasticsearch;
return new class {
public function up(): void
{
Elasticsearch::indices()->create([
'index' => 'products_v1',
'body' => [
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
'mappings' => [
'properties' => [
'title' => ['type' => 'text'],
'brand' => ['type' => 'keyword'],
'price' => ['type' => 'scaled_float', 'scaling_factor' => 100],
'created_at' => ['type' => 'date'],
],
],
],
]);
Elasticsearch::indices()->putAlias(['index' => 'products_v1', 'name' => 'products']);
}
public function down(): void
{
Elasticsearch::indices()->delete(['index' => 'products_v1']);
}
};
5. Run and verify
php artisan elastic:migrate
curl -s localhost:9200/_cat/indices?v
curl -s localhost:9200/products/_mapping
The package records applied migrations in a table in your SQL database, not in Elasticsearch. That single fact drives most of the gotchas below.
Schema in migrations, data in commands. Migrations should only create/delete indices, aliases, templates, and pipelines. Bulk indexing belongs in a dedicated php artisan search:reindex command or a seeder. Mixing them means every elastic:migrate:refresh in CI reindexes your whole catalog, and a failed document silently half-populates an index you just created.
Versioned index + alias, always. Never let application code (or the Scout driver's index config) reference a concrete index name like products_v1. Point it at the alias products. Then a reindex migration is:
products_v2 with the new mapping.v1 → v2.indices()->updateAliases() to atomically move products from v1 to v2.products_v1.This is the only way to change a field's type — Elasticsearch will reject in-place mapping changes for existing fields.
Wrap the same three calls in a private helper. You'll write "create index with these mappings, then put alias" dozens of times. Put a small SearchIndex helper in app/Support and call it from migrations. Migrations stay three lines each and the mapping payloads stay reviewable.
Deploy ordering. In your deploy script, run php artisan migrate --force (SQL) and then php artisan elastic:migrate — SQL first, because the migration tracking table lives there. Treat the Elasticsearch step as non-blocking-with-alert if you can tolerate a stale mapping for a few seconds; treat an index deletion migration as strictly blocking.
Testing. In CI, spin up a throwaway Elasticsearch container and run php artisan elastic:migrate in the suite's setUpBeforeClass or as a CI step. Use a per-environment index prefix (from config) so parallel test runs and local dev don't collide on the same cluster. If you already use RefreshDatabase, add a sibling trait that calls elastic:migrate:refresh before each test class that touches search — but be aware it is genuinely destructive, so only ever enable it when the configured host is your test host.
Batches mirror Laravel. elastic:migrate:rollback rolls back the most recent batch, elastic:migrate:reset rolls back everything, elastic:migrate:refresh does reset-then-migrate. Group an index creation and its alias creation into the same migration file rather than two files, or a rollback can leave an orphaned index or a dangling alias pointing at nothing.
Pair with the Scout driver deliberately. Whatever index name you put in the model's searchableAs(), the migration must create it (or its alias). Keep that string in one config constant both sides read, because a typo produces the classic "index_not_found_exception" at query time, not at migration time.
Elasticsearch has no transactions. A migration that creates an index and then puts an alias can fail halfway, and the package will mark it as not applied — so the next run tries again and trips over the already-created index. Make up() defensive:
if (! Elasticsearch::indices()->exists(['index' => 'products_v1'])->asBool()) { ... }
(Response objects differ between client majors — some return arrays, some return Elastic\Elasticsearch\Response\Elasticsearch where you need ->asBool() or ->asArray(). The generated stub and your installed client's docs tell you which.)
State lives in SQL, the resources live in Elasticsearch. These two drift constantly:
php artisan migrate:fresh on a dev box leaves Elasticsearch untouched, then elastic:migrate fails because indices already exist.elastic:migrate does nothing and your app 404s on every query.The fix is a documented one-liner for the team: truncate the migration table and delete the indices, then re-migrate. Add that to the README.
down() deletes data. Rolling back an index creation destroys every document in it. For production indices, consider making down() only remove the alias and leave the index for manual cleanup, and require an explicit --force-style acknowledgement before running rollbacks at all. Check whether the installed version guards destructive commands in production the way Laravel's own migrate does — verify what elastic:migrate:reset does on your production .env before someone fat-fingers it.
Multiple environments sharing one cluster. Staging and production pointing at the same Elasticsearch host is common in small teams and catastrophic here: identical index names mean staging's refresh wipes production. Enforce an APP_ENV-derived prefix in the config, and assert in a test that production config never resolves to the staging host.
Replicas on a single node. Set number_of_replicas: 0 in your dev mapping. The default of 1 gives you a yellow cluster and hours lost to "why is my cluster unhealthy" when it's just one machine.
Mapping changes need a rebuild, not a putMapping. putMapping can only add new fields; it cannot change text → keyword or change an analyzer. If a migration tries, you get a mapper_parsing_exception or a silent no-op. Reach for the versioned-index-and-alias-swap pattern instead, every time.
Version alignment. The package version must match the elasticsearch/elasticsearch major you have installed (7.x vs 8.x). The 8.x client dropped types and changed response objects; a migration copied from a 7.x tutorial will parse fine and then return an object your code treats as an array. Check the compatibility table in the README before upgrading either side.
Debugging a failing migration. The exception message usually contains the Elasticsearch error body, but it's buried. Temporarily wrap the call:
try {
Elasticsearch::indices()->create([...]);
} catch (\Throwable $e) {
dump($e->getMessage()); // includes the ES "reason" field
throw $e;
}
Then confirm what actually landed with _cat/indices, _cat/aliases, and _mapping. A "successful" migration and a correctly configured index are not the same thing.
Extension points. Publish the config to relocate the migration directory or set an index prefix; swap the client binding in a service provider if you need a custom connection (e.g., a separate read-only cluster for reporting); write your own Artisan command that calls the same runner if you need migrations gated behind a feature flag or a blue/green cutover step.
How can I help you explore Laravel packages today?