rikudou/doctrine-plural-naming
A Doctrine DBAL naming strategy that automatically pluralizes table names using Laravel's pluralizer, keeping database schemas consistent without manual table naming. Drop-in integration for Laravel projects using Doctrine.
What it is. A single-class Doctrine ORM naming strategy that turns entity class names into pluralized snake_case table names (UserProfile → user_profiles). It only affects table names; column names, reference columns, and join columns use the parent Doctrine strategy's behavior. In practice you're enabling a convention, not integrating a framework.
Install
composer require rikudou/doctrine-plural-naming
Before wiring it in, check vendor/rikudou/doctrine-plural-naming/src/ and composer.json — the surface is tiny (typically PluralUnderscoreNamingStrategy and PluralCamelCaseNamingStrategy), you want to confirm the Doctrine ORM/DBAL constraint intersects your lockfile, and you want to confirm pluralization delegates to doctrine/inflector. That's the whole vetting exercise.
Wiring — standalone Doctrine
use Doctrine\ORM\Configuration;
use RikudouSage\DoctrinePluralNaming\PluralUnderscoreNamingStrategy;
$config = new Configuration();
$config->setNamingStrategy(new PluralUnderscoreNamingStrategy());
Wiring — Symfony / DoctrineBundle
# config/packages/doctrine.yaml
doctrine:
orm:
naming_strategy: RikudouSage\DoctrinePluralNaming\PluralUnderscoreNamingStrategy
Wiring — Laravel with laravel-doctrine/orm
// config/doctrine.php
'managers' => [
'default' => [
'naming_strategy' => \RikudouSage\DoctrinePluralNaming\PluralUnderscoreNamingStrategy::class,
],
],
The exact key depends on the Doctrine integration you use; the invariant is that a NamingStrategy instance is set on the Configuration before the EntityManager is built. If you don't see that hook in your integration, resolve a service named doctrine.orm.naming_strategy.default.
Where to look first
classToTableName() — the only method that changes day-to-day behavior. Everything else is inherited.joinTableName() — many-to-many pivots also get pluralized; verify against your reporting queries.doctrine/inflector usage) — this is where irregular plurals live, and where you'll debug surprises.PluralUnderscoreNamingStrategy is the safe default matching SQL conventions.First use case
Greenfield service where you want users, orders, line_items without #[ORM\Table] on every entity. Turn the strategy on, drop #[ORM\Table] from all entities, then validate before any migration runs:
php bin/console doctrine:schema:validate
Then confirm at the metadata level:
$meta = $em->getClassMetadata(App\Entity\User::class);
echo $meta->getTableName(); // "users"
Only after those two checks pass should you generate a migration.
Default-to-convention, override for irregulars. The strategy is a default. An explicit #[ORM\Table(name: 'people')] always wins, so keep a short override list for Person, Mouse, Criterion, Status-style nouns instead of trying to make inflection perfect. Reviewers should expect an empty diff when adding a new entity; if they see a #[ORM\Table] annotation, it should be justified.
Adopting on an existing schema — pin first, adopt second. Adding the strategy to a production app doesn't rename tables; it renames them only once you call schema:update or regenerate migrations. That's the dangerous path. Two safe patterns:
#[ORM\Table] everywhere so the strategy has no effect on legacy entities, and only use pluralization for new entities. You get the default-forward but stay pin-stable.Custom pluralization via subclass. Don't patch the vendor file. Subclass and override just the pluralization step:
final class DomainNamingStrategy extends PluralUnderscoreNamingStrategy
{
protected function pluralize(string $word): string
{
return match (strtolower($word)) {
'person' => 'people',
'criterion'=> 'criteria',
'schema' => 'schemas',
default => parent::pluralize($word),
};
}
}
Register the subclass as a service and point naming_strategy at the service id. This means vendor upgrades touch one seam.
Unit-test the strategy without a database. It's a pure function; the test lives outside your fixtures:
final class NamingStrategyTest extends TestCase
{
public function testRealVocabulary(): void
{
$s = new DomainNamingStrategy();
self::assertSame('user_groups', $s->classToTableName(App\UserGroup::class));
self::assertSame('people', $s->classToTableName(App\Person::class));
self::assertSame('order_items', $s->classToTableName(App\OrderItem::class));
}
}
Keep this test alongside your real entity vocabulary. It's the regression net when the package or Doctrine bumps a minor version.
Join tables — verify, don't assume. Many-to-many pivot names are generated by the strategy too. Run a schema diff and grep:
php bin/console doctrine:schema:update --dump-sql | grep -i 'create table'
If a specific pivot name is externally visible (reporting, BI, external FK references), annotate it with #[ORM\JoinTable(name: '...')] in the owning entity.
Migration workflow with a naming strategy change. Treat it as a schema migration event, not a config change:
#[ORM\Table] on all existing entities.Enforce with a lint check. A simple PHPStan rule or CI grep for #[ORM\Table] in the entity namespace catches drift. Without this you end up with a mix of pluralized and singular tables, which is worse than either convention applied consistently.
Pair it with your migrations. Your migration files will contain literal table names — those are the source of truth once shipped. Any naming-strategy change that would rename a table must be expressed as an explicit migration, not left to schema:update.
It only changes table names. Column names, join columns, and identifier columns are inherited from the parent UnderscoreNamingStrategy. If you're expecting $userProfile->getEmailAddress() to map to email_address and it doesn't, that's the parent strategy, not this package.
Irregular nouns are the number-one source of production incidents. Person → people, Status → statuses, Analysis → analyses, and uncountables like Data are classic. Any table name that ends up in a hand-written query or an external report should have an #[ORM\Table] override pinning it.
The rename failure mode is late. A mis-pluralized table name doesn't fail at boot — it fails at query time on whichever code path touches that entity. If your integration tests don't hit that entity, the failure ships. Add a smoke test that touches every entity at least once (find(0) is enough) right after adopting the strategy.
Silent no-op if the strategy isn't wired. If your Doctrine integration resolves naming_strategy only for a specific connection, or a config layer overwrites it, the app boots fine and you get singular table names. Verify with:
echo get_class($em->getConfiguration()->getNamingStrategy());
Do this in a CI health check for services that depend on plural tables.
Compatibility across DBAL 2 / 3 and ORM 2 / 3. Doctrine's NamingStrategy moved interfaces between versions. If your platform is on a modern DBAL 3/ORM 3 stack, confirm the package's constraint isn't pinned to an older version. If it is, don't try to make it work — vendor the strategy (it's ~40 lines) into your namespace with a Doctrine-version-native interface.
quoted identifiers and PostgreSQL. Pluralization can produce names that collide with reserved words (order, user, group). Doctrine's quoted metadata flag, or identifier_quoting in the Configuration, is what protects you. If you see syntax error at or near "user" in Postgres, that's the fix — not a naming-strategy change.
Inflection dependency should be explicit. If the package relies on doctrine/inflector but doesn't declare it as a direct dependency in its composer.json, you can get a version bump through Doctrine's transitive deps that changes a plural. Pin doctrine/inflector in your own composer.json and lock it.
Double-pluralization with mixed conventions. If any entities already have plural table names via annotation (#[ORM\Table(name: 'users')]), the strategy is a no-op for them — that's fine. But if you have an entity class literally named Users, you'll get userses. Rename the class rather than fighting the strategy.
Custom strategy service wiring. In Symfony, naming_strategy accepts a class string or a service id. If your subclass has constructor arguments, use a service id and mark the service public, or point to a factory. If you pass a class string for a class that requires arguments, Doctrine will try to instantiate it with no args and blow up at EntityManager construction — before any application code runs, which is at least an easy failure to diagnose.
Join table naming can surprise you. joinTableName() combines both sides; the exact form (e.g., user_role vs roles_users) depends on the strategy's implementation and which side is owning. Never hardcode pivot names in raw SQL — read them from ClassMetadata::getAssociationMapping($field)['joinTable']['name'] in tests.
Extension point is classToTableName (and joinTableName). Everything else is inherited. If you need to change column naming, extend the appropriate Doctrine strategy and call the naming strategy only for tables — but at that point, consider vendoring both behaviors into one class.
Don't add this if you're Eloquent-only. Laravel's default pluralization is already correct for Eloquent. This package only matters if you're running Doctrine ORM inside the app. The cost of adding it to an Eloquent-only project is zero benefit plus an unmaintained dependency.
Vendoring is a legitimate option. With 0 stars and 0 dependents, the support surface is the source file itself. If you're worried about abandonment or Doctrine-version drift, copy the strategy class into app/Doctrine/, add the two or three inflection cases you care about, and test it. You lose nothing but the composer require.
How can I help you explore Laravel packages today?