intaro/custom-index-bundle
Symfony bundle for adding and managing custom Doctrine database indexes via configuration, so you can tune query performance without modifying entity mappings. Useful for optimizing large tables and controlling indexes from application config.
First, the reality check. intaro/custom-index-bundle is a Symfony bundle built on Doctrine ORM, not a Laravel package. composer require intaro/custom-index-bundle in an Eloquent app pulls in Symfony/Doctrine dependencies and does nothing — there is no service provider, no config file, no schema hook Laravel understands. With 27 stars, zero dependents, and a single maintainer, treat it as a reference implementation of a pattern, not a dependency you adopt.
What v1.1.0 changes (and doesn't). The release is maintenance, not features: Docker dev env, php-cs-fixer, PHPStan, CI, assorted fixes, and — the only item that matters to you — doctrine/orm 3 compatibility. There is still no Laravel integration, no service provider, no new index capability. Two practical consequences:
^1.1; on ORM 2, 1.0.3 remains fine and 1.1.0 should also work.There are only two scenarios where the code itself is useful to you:
mitchellvanw/laravel-doctrine or a custom integration). Then you can register the bundle's event subscriber manually and get its attribute DSL for free — and as of 1.1.0, on ORM 3.Where to look first, in this order:
src/Annotations/CustomIndex.php or src/Attribute/...). This is the entire public DSL: index name, columns, unique, using (btree/gin/gist/brin/hash), and where for partial indexes. Unchanged in 1.1.0 — the API surface is stable, which is good news for anyone vendoring it.postGenerateSchema / onSchemaCreateTable. This is where the bundle decides when to emit DDL — and it's the piece you'd reimplement as a migration. Check this file specifically when upgrading to 1.1.0: ORM 3 reworked schema/event APIs, so if you have a custom integration wired against the old signature, that's your breakage point.Your first use case should be the one that made you search for this package: a partial index, e.g. CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending', or a GIN index on a jsonb column. Write the equivalent DB::statement() in a Laravel migration, then diff it against what the bundle generates for the same definition — easiest now by cloning the repo, using the new Docker env, and running the platform generator tests. If they match, you've extracted the value and can uninstall it.
The attribute-on-entity pattern (Doctrine only). The bundle's model is class-level metadata:
#[CustomIndex(name: 'users_email_lower_idx', columns: ['lower(email)'], unique: true)]
class User { ... }
Then doctrine:migrations:diff picks it up and emits the DDL. The key architectural idea to steal: index definitions live next to the model, but DDL is generated by a migration tool, not at runtime. Nothing happens at application boot. That's a good property — replicate it.
The Eloquent equivalent — a dedicated migration per index type. Keep custom indexes out of $table->index() (which can't express expressions, WHERE, USING, or INCLUDE) and out of your create_* migrations (which should stay portable). One migration, one DB::statement, guarded by driver:
public function up(): void
{
if (DB::getDriverName() !== 'pgsql') return;
DB::statement("CREATE INDEX CONCURRENTLY orders_pending_idx
ON orders (created_at) WHERE status = 'pending'");
}
Pattern catalog worth adopting:
SoftDeletes global scope, because the scope emits deleted_at IS NULL on every query — which is exactly the predicate the planner needs to use the index. This is the highest-value pattern for a typical Laravel app.jsonb for whereJsonContains / -> operator queries. Match the index's opclass to the operators you actually use (jsonb_path_ops is smaller but only supports @>).lower(email) to enforce case-insensitive uniqueness without a citext extension. Remember the query must also be whereRaw('lower(email) = ?') — where('email', ...) won't use it.created_at for append-only, time-ordered tables (logs, events) where a btree would be huge.INCLUDE) for hot list endpoints where you want an index-only scan.Integration tips:
public $withinTransaction = false; on the migration when using CREATE INDEX CONCURRENTLY — Laravel wraps migrations in a transaction by default and Postgres will reject it.down().pg_indexes for the index name in CI. It's the only way to catch a migration that silently no-ops because of a driver check.1.0.3 before you swap.USING gin|gist|brin, WHERE, INCLUDE, and expression columns have no MySQL equivalent (MySQL 8 has functional indexes but with different syntax and no partial indexes at all). The bundle's per-platform generator exists precisely to skip or fail on unsupported drivers — expect the same in your port, and be explicit about whether "unsupported" means silent skip or exception. Silent skip is how indexes go missing in production.$table->index('lower(email)') produces a broken identifier. Raw SQL only, and the down() must be raw too.DROP INDEX on Postgres takes just the name — which is why deterministic naming matters. On MySQL you need ON table. Don't share a down() between drivers.#[ORM\Index] or $table->index(['status','created_at']) and a custom partial index on the same columns, both get maintained on every write. Audit pg_indexes for overlapping column lists before shipping.down() can never find, and one your schema-assertion test will fail on mysteriously.CREATE INDEX CONCURRENTLY cannot run inside a transaction, cannot run on a table with a pending schema change, and can leave an INVALID index behind on failure. After any concurrent index build, check pg_index.indisvalid and REINDEX if needed.doctrine:schema:update --dump-sql still emits your custom indexes. If you vendored the source, git diff v1.0.3..v1.1.0 -- src/ and focus on the platform generator and annotation reader — the php-cs/phpstan pass may have altered typing or null-coalescing in the SQL-building path.doctrine:schema:update --dump-sql is the fastest way to see what the bundle actually intends to emit. In Laravel, DB::listen() or php artisan migrate --pretend (which does show raw statements, but won't execute them).app/Support/ is more defensible than depending on a 27-star package with no dependents and a single maintainer — you get the pattern, the SQL generation logic, and zero upgrade risk. Pin the version if you do depend on it, and assume you'll be maintaining the fork. The new CI/PHPStan setup modestly improves the odds the package stays healthy, but one contributor plus one release in a year is still a signal, not a guarantee.How can I help you explore Laravel packages today?