# Laravel Doctrine ORM Laravel Doctrine ORM (`laravel-doctrine/orm`) is an integration library that bridges the Laravel framework with Doctrine ORM, bringing the data-mapper pattern to Laravel applications. Unlike Eloquent's active-record approach, Doctrine ORM enforces a clean separation between domain/business logic and persistence concerns, allowing PHP entity classes to remain pure domain objects without extending any base class. Version 3 supports Laravel 10+, Doctrine ORM ^3.0, Doctrine DBAL ^3.0|^4.0, and PHP ^8.2, with autodiscovery of its `DoctrineServiceProvider` and facades. The library provides a complete ORM ecosystem on top of Laravel: a fully configured `EntityManager` accessible via facade, dependency injection, or the service container; an `IlluminateRegistry` managing multiple entity managers and connections; attribute- and XML-based entity metadata mapping; multiple built-in cache drivers (Redis, Memcached, APC, file, array); Laravel auth/validation/pagination/notification integration; entity factories for testing; and an extensible architecture via the `ExtensionManager` and `DoctrineManager` for deep customization of each entity manager. --- ## Installation and Configuration ### Install and Publish Config Install via Composer and publish the configuration file to `config/doctrine.php`. ```bash composer require laravel-doctrine/orm php artisan vendor:publish --tag="config" --provider="LaravelDoctrine\ORM\DoctrineServiceProvider" ``` ### Full `config/doctrine.php` Reference The published config controls entity managers, caching, extensions, custom types, and more. ```php // config/doctrine.php return [ 'managers' => [ 'default' => [ 'dev' => env('APP_DEBUG', false), 'meta' => env('DOCTRINE_METADATA', 'attributes'), // attributes|xml|simplified_xml|static_php|php 'connection' => env('DB_CONNECTION', 'mysql'), 'paths' => [ base_path('app/Doctrine/ORM/Entity'), ], 'repository' => Doctrine\ORM\EntityRepository::class, 'proxies' => [ 'namespace' => 'DoctrineProxies', 'path' => storage_path('proxies'), 'auto_generate' => env('DOCTRINE_PROXY_AUTOGENERATE', false), // true in dev only ], 'events' => [ 'listeners' => [ // Doctrine\ORM\Events::onFlush => MyFlushListener::class, ], 'subscribers' => [ // MyEventSubscriber::class, ], ], 'filters' => [], 'mapping_types' => [ // 'enum' => 'string', ], 'middlewares' => [ // Doctrine\DBAL\Logging\Middleware::class, ], ], // Add more managers for multi-tenancy or multiple databases: // 'secondary' => [ ... ], ], 'extensions' => [ // LaravelDoctrine\Extensions\Timestamps\TimestampableExtension::class, // LaravelDoctrine\Extensions\SoftDeletes\SoftDeleteableExtension::class, // LaravelDoctrine\Extensions\Sluggable\SluggableExtension::class, ], 'custom_types' => [], 'custom_datetime_functions' => [], 'custom_numeric_functions' => [], 'custom_string_functions' => [], 'custom_hydration_modes' => [], 'cache' => [ 'second_level' => false, 'default' => env('DOCTRINE_CACHE', 'array'), // apc|array|file|illuminate|memcached|php_file|redis 'namespace' => null, 'metadata' => ['driver' => env('DOCTRINE_METADATA_CACHE', 'array'), 'namespace' => 'metadata'], 'query' => ['driver' => env('DOCTRINE_QUERY_CACHE', 'array'), 'namespace' => 'query'], 'result' => ['driver' => env('DOCTRINE_RESULT_CACHE', 'array'), 'namespace' => 'result'], ], 'doctrine_presence_verifier' => true, 'notifications' => ['channel' => 'database'], ]; ``` ### Environment Variables ```dotenv DOCTRINE_METADATA=attributes # attributes|xml|simplified_xml|static_php|php DOCTRINE_PROXY_AUTOGENERATE=false # true in dev, false in prod DOCTRINE_CACHE=array # apc|array|file|memcached|redis DOCTRINE_METADATA_CACHE=redis DOCTRINE_QUERY_CACHE=redis DOCTRINE_RESULT_CACHE=redis DOCTRINE_LOGGER= # optional DQL query logger ``` --- ## Defining Entities ### Entity with PHP Attributes Metadata Doctrine entities are plain PHP classes with no base class. Relationships are typed as `Doctrine\Common\Collections\Collection` and initialized to `ArrayCollection` in the constructor. ```php namespace App\Doctrine\ORM\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; #[ORM\Entity(repositoryClass: \App\Doctrine\ORM\Repository\ScientistRepository::class)] #[ORM\Table(name: 'scientists')] class Scientist { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue(strategy: 'AUTO')] private int $id; #[ORM\Column(type: 'string', nullable: false)] private string $firstName; #[ORM\Column(type: 'string', nullable: false)] private string $lastName; #[ORM\OneToMany(targetEntity: Theory::class, mappedBy: 'scientist', cascade: ['persist', 'remove'])] private Collection $theories; public function __construct() { $this->theories = new ArrayCollection(); } public function getId(): int { return $this->id; } public function getFirstName(): string { return $this->firstName; } public function setFirstName(string $firstName): void { $this->firstName = $firstName; } public function getTheories(): Collection { return $this->theories; } } #[ORM\Entity(repositoryClass: \App\Doctrine\ORM\Repository\TheoryRepository::class)] class Theory { #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue(strategy: 'AUTO')] private int $id; #[ORM\Column(type: 'string', nullable: false)] private string $title; #[ORM\ManyToOne(targetEntity: Scientist::class, inversedBy: 'theories')] #[ORM\JoinColumn(name: 'scientist_id', referencedColumnName: 'id', nullable: false)] private Scientist $scientist; } ``` ### Entity with XML Metadata Place XML files in `config/doctrine_orm_metadata/` using the naming convention `Namespace.Class.dcm.xml`. ```xml ``` ```php // config/doctrine.php — point the metadata path to your XML directory 'paths' => [config_path('doctrine_orm_metadata')], 'meta' => 'xml', ``` --- ## EntityManager: Persist, Find, Update, Remove ### EntityManager::persist(), flush(), find(), remove() The `EntityManager` facade (or injected `EntityManagerInterface`) is the central access point for all ORM operations. Changes are only written to the database when `flush()` is called. ```php use LaravelDoctrine\ORM\Facades\EntityManager; use App\Doctrine\ORM\Entity\Scientist; use App\Doctrine\ORM\Entity\Theory; // --- CREATE --- $scientist = new Scientist(); $scientist->setFirstName('Albert'); $scientist->setLastName('Einstein'); $theory = new Theory(); $theory->setTitle('Theory of Relativity'); $theory->setScientist($scientist); $scientist->getTheories()->add($theory); EntityManager::persist($scientist); // schedules INSERT EntityManager::persist($theory); EntityManager::flush(); // executes SQL // --- READ (Identity Map: same instance returned for same ID) --- $same = EntityManager::find(Scientist::class, $scientist->getId()); assert($same === $scientist); // true — Doctrine's Identity Map pattern // --- UPDATE --- $entity = EntityManager::find(Scientist::class, 1); $entity->setFirstName('Isaac'); EntityManager::flush(); // Doctrine detects change via Unit of Work, no persist() needed // --- DELETE --- $entity = EntityManager::find(Scientist::class, 1); EntityManager::remove($entity); EntityManager::flush(); // --- Accessing via container --- $em = app(\Doctrine\ORM\EntityManagerInterface::class); $em = app('em'); ``` ### Dependency Injection in Controllers ```php use Doctrine\ORM\EntityManagerInterface; use App\Doctrine\ORM\Entity\Scientist; class ScientistController extends Controller { public function __construct(private readonly EntityManagerInterface $em) {} public function store(Request $request): JsonResponse { $scientist = new Scientist(); $scientist->setFirstName($request->input('first_name')); $scientist->setLastName($request->input('last_name')); $this->em->persist($scientist); $this->em->flush(); return response()->json(['id' => $scientist->getId()], 201); } } ``` --- ## Repositories ### Custom Repository via Inheritance Bind the repository through a service provider so it can be injected by interface throughout the application. ```php // app/Doctrine/ORM/Repository/DoctrineScientistRepository.php use Doctrine\ORM\EntityRepository; use App\Doctrine\ORM\Repository\ScientistRepositoryInterface; class DoctrineScientistRepository extends EntityRepository implements ScientistRepositoryInterface { public function findByLastName(string $lastName): array { return $this->findBy(['lastName' => $lastName], ['firstName' => 'ASC']); } public function findActive(): array { return $this->createQueryBuilder('s') ->where('s.active = :active') ->setParameter('active', true) ->getQuery() ->getResult(); } } // app/Providers/AppServiceProvider.php use App\Doctrine\ORM\Entity\Scientist; public function register(): void { $this->app->bind(ScientistRepositoryInterface::class, function ($app) { return new DoctrineScientistRepository( $app['em'], $app['em']->getClassMetadata(Scientist::class) ); }); } // In a controller class ScientistController extends Controller { public function __construct(private readonly ScientistRepositoryInterface $scientists) {} public function index(): JsonResponse { return response()->json($this->scientists->findActive()); } } ``` --- ## Multiple Entity Managers ### Configuring and Accessing Multiple Managers Use `ManagerRegistry` to access a specific named entity manager when multiple are configured. ```php // config/doctrine.php 'managers' => [ 'default' => [ 'connection' => 'mysql', 'paths' => [base_path('app/Doctrine/ORM/Entity')], 'meta' => 'attributes', 'proxies' => ['path' => storage_path('proxies'), 'auto_generate' => false], ], 'audit' => [ 'connection' => 'pgsql', // different database 'paths' => [base_path('app/Doctrine/Audit/Entity')], 'meta' => 'attributes', 'proxies' => ['path' => storage_path('proxies/audit'), 'auto_generate' => false], ], ], ``` ```php use Doctrine\Persistence\ManagerRegistry; use Doctrine\ORM\EntityManagerInterface; class AuditController extends Controller { private EntityManagerInterface $auditEm; public function __construct(ManagerRegistry $registry) { // Get a specific named manager $this->auditEm = $registry->getManager('audit'); // Or resolve by entity class $defaultEm = $registry->getManagerForClass(\App\Doctrine\ORM\Entity\Scientist::class); } } ``` --- ## Authentication ### Doctrine User Entity and Auth Configuration Implement Laravel's `Authenticatable` contract on your Doctrine entity and configure the `doctrine` driver in `config/auth.php`. ```php // app/Doctrine/ORM/Entity/User.php use Doctrine\ORM\Mapping as ORM; use Illuminate\Contracts\Auth\Authenticatable; use LaravelDoctrine\ORM\Auth\Authenticatable as AuthenticatableTrait; #[ORM\Entity] #[ORM\Table(name: 'users')] class User implements Authenticatable { use AuthenticatableTrait; // provides getAuthIdentifierName, getAuthIdentifier, getAuthPassword, etc. #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue(strategy: 'AUTO')] protected int $id; #[ORM\Column(type: 'string', unique: true)] protected string $email; #[ORM\Column(type: 'string')] protected string $password; public function getAuthIdentifierName(): string { return 'id'; } } ``` ```php // config/auth.php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'doctrine_users', ], ], 'providers' => [ 'doctrine_users' => [ 'driver' => 'doctrine', 'model' => \App\Doctrine\ORM\Entity\User::class, ], ], ``` --- ## Password Resets ### Doctrine-backed Password Reset Service Replace Laravel's default `PasswordResetServiceProvider` so token storage uses Doctrine. ```php // config/app.php — replace the default provider 'providers' => [ // Remove: Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, LaravelDoctrine\ORM\Auth\Passwords\PasswordResetServiceProvider::class, // ... ], ``` ```php // app/Doctrine/ORM/Entity/User.php — add CanResetPassword use Illuminate\Auth\Passwords\CanResetPassword; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; class User implements Authenticatable, CanResetPasswordContract { use AuthenticatableTrait; use CanResetPassword; // provides getEmailForPasswordReset(), sendPasswordResetNotification() #[ORM\Column(type: 'string')] protected string $email; } ``` --- ## Pagination ### PaginatesFromRequest and PaginatesFromParams Traits Add the pagination traits to repositories to return `LengthAwarePaginator` instances compatible with Laravel's pagination views. ```php namespace App\Doctrine\ORM\Repository; use Doctrine\ORM\EntityRepository; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use LaravelDoctrine\ORM\Pagination\PaginatesFromParams; class DoctrineScientistRepository extends EntityRepository { use PaginatesFromParams; // Returns all scientists, 15 per page by default public function all(int $perPage = 15, int $page = 1): LengthAwarePaginator { return $this->paginateAll($perPage, $page); } // Custom DQL query with pagination public function searchByName(string $name, int $perPage = 15, int $page = 1): LengthAwarePaginator { $query = $this->createQueryBuilder('s') ->where('s.firstName LIKE :name OR s.lastName LIKE :name') ->orderBy('s.lastName', 'ASC') ->setParameter('name', "%{$name}%") ->getQuery(); return $this->paginate($query, $perPage, $page); } } // In a controller public function index(Request $request): View { $page = $request->input('page', 1); $scientists = $this->scientists->all(perPage: 20, page: $page); // $scientists is a LengthAwarePaginator — use {{ $scientists->links() }} in Blade return view('scientists.index', compact('scientists')); } ``` --- ## Validation ### unique and exists Rules Against Doctrine Entities The `doctrine_presence_verifier` setting wires Laravel's `unique` and `exists` validation rules to query through Doctrine rather than raw DB. ```php // unique: EntityClass,column[,exceptId[,idColumn]] // exists: EntityClass,column class UserController extends Controller { public function store(Request $request): RedirectResponse { $validated = $request->validate([ 'username' => 'required|unique:App\Doctrine\ORM\Entity\User,username', 'email' => 'required|email|unique:App\Doctrine\ORM\Entity\User,email', 'role_id' => 'required|exists:App\Doctrine\ORM\Entity\Role,id', ]); // ... } public function update(Request $request, int $id): RedirectResponse { $validated = $request->validate([ // Ignore the current user's ID during uniqueness check 'email' => "required|email|unique:App\Doctrine\ORM\Entity\User,email,{$id},id", ]); // ... } } ``` --- ## Notifications ### Doctrine Channel for Database-stored Notifications Store notifications via Doctrine by creating a Notification entity, implementing `toEntity()` in your notification class, and using the `Notifiable` trait on your User entity. ```php // app/Doctrine/ORM/Entity/Notification.php namespace App\Doctrine\ORM\Entity; use Doctrine\ORM\Mapping as ORM; use LaravelDoctrine\ORM\Notifications\Notification as BaseNotification; #[ORM\Entity] class Notification extends BaseNotification { #[ORM\ManyToOne(targetEntity: User::class)] protected $notifiable; } ``` ```php // app/Notifications/InvoicePaid.php namespace App\Notifications; use LaravelDoctrine\ORM\Notifications\DoctrineChannel; use App\Doctrine\ORM\Entity\Notification; class InvoicePaid extends \Illuminate\Notifications\Notification { public function __construct(private readonly float $amount) {} public function via(mixed $notifiable): array { return [DoctrineChannel::class]; } public function toEntity(mixed $notifiable): Notification { return (new Notification()) ->to($notifiable) ->success() ->message("Invoice for \${$this->amount} has been paid.") ->action('View Invoice', route('invoices.show', 1)); } } // app/Doctrine/ORM/Entity/User.php — add Notifiable trait use LaravelDoctrine\ORM\Notifications\Notifiable; class User implements Authenticatable { use Notifiable; // Optionally route to a specific entity manager public function routeNotificationForDoctrine(): string { return 'default'; } } // Dispatching the notification $user->notify(new InvoicePaid(149.99)); ``` --- ## Testing with Entity Factories ### Defining and Using Entity Factories Entity factories live in `database/factories/` and use Faker to generate test data. Use `make()` for in-memory instances or `create()` to persist to the database. ```php // database/factories/UserFactory.php /** @var \LaravelDoctrine\ORM\Testing\Factory $factory */ $factory->define(\App\Doctrine\ORM\Entity\User::class, function (\Faker\Generator $faker) { return [ 'firstName' => $faker->firstName, 'lastName' => $faker->lastName, 'email' => $faker->unique()->safeEmail, 'password' => bcrypt('secret'), ]; }); $factory->defineAs(\App\Doctrine\ORM\Entity\User::class, 'admin', function (\Faker\Generator $faker) { return [ 'firstName' => $faker->firstName, 'lastName' => $faker->lastName, 'email' => $faker->unique()->safeEmail, 'password' => bcrypt('secret'), 'isAdmin' => true, ]; }); ``` ```php // tests/Feature/UserTest.php use LaravelDoctrine\ORM\Facades\EntityManager; class UserTest extends TestCase { public function test_can_create_user(): void { // Make (not persisted) $user = entity(\App\Doctrine\ORM\Entity\User::class)->make(); $this->assertInstanceOf(\App\Doctrine\ORM\Entity\User::class, $user); // Create (persisted to DB) $admin = entity(\App\Doctrine\ORM\Entity\User::class, 'admin')->create(); $this->assertNotNull($admin->getId()); // Create multiple $users = entity(\App\Doctrine\ORM\Entity\User::class, 3)->create(); $this->assertCount(3, $users); // Override specific attributes $named = entity(\App\Doctrine\ORM\Entity\User::class)->make(['firstName' => 'Alan']); $this->assertSame('Alan', $named->getFirstName()); } } ``` ### Database Transactions in Tests ```php // tests/DoctrineDatabaseTransactions.php namespace Tests; use LaravelDoctrine\ORM\Facades\EntityManager; trait DoctrineDatabaseTransactions { public function setUpDoctrineDatabaseTransactions(): void { EntityManager::getConnection()->beginTransaction(); } public function tearDownDoctrineDatabaseTransactions(): void { EntityManager::getConnection()->rollBack(); } } // Share the connection with Laravel's assertDatabaseHas() $pdo = app(\Doctrine\ORM\EntityManagerInterface::class) ->getConnection() ->getWrappedConnection(); app(\Illuminate\Database\ConnectionInterface::class)->setPdo($pdo); ``` --- ## DoctrineManager — Advanced Configuration ### Extending Entity Managers at Runtime `DoctrineManager` (facade `Doctrine`) gives direct access to `Configuration`, `Connection`, and `EventManager` per named entity manager after they are built. ```php // app/Providers/AppServiceProvider.php use LaravelDoctrine\ORM\Facades\Doctrine; use LaravelDoctrine\ORM\DoctrineManager; use Doctrine\ORM\Configuration; use Doctrine\DBAL\Connection; use Doctrine\Common\EventManager; public function boot(DoctrineManager $manager): void { // Extend a specific manager by name $manager->extend('default', function (Configuration $config, Connection $conn, EventManager $events) { // Register a custom DQL function $config->addCustomStringFunction('MATCH_AGAINST', \App\Doctrine\DQL\MatchAgainst::class); // Register a custom type mapping $conn->getDatabasePlatform()->registerDoctrineTypeMapping('point', 'string'); // Add an event listener $events->addEventListener( [\Doctrine\ORM\Events::postLoad], new \App\Doctrine\Listener\PostLoadListener() ); }); // Extend ALL managers at once $manager->extendAll(function (Configuration $config, Connection $conn, EventManager $events) { $config->setAutoCommit(false); }); } ``` ### Implementing a Reusable Extender Class ```php use LaravelDoctrine\ORM\DoctrineExtender; use Doctrine\ORM\Configuration; use Doctrine\DBAL\Connection; use Doctrine\Common\EventManager; class LoggingExtender implements DoctrineExtender { public function extend(Configuration $configuration, Connection $connection, EventManager $eventManager): void { $configuration->setMiddlewares([ new \Doctrine\DBAL\Logging\Middleware(app('log')->channel('doctrine')), ]); } } // In a ServiceProvider boot() method: app(DoctrineManager::class)->extend('default', LoggingExtender::class); ``` --- ## Caching ### Configuring and Extending Cache Drivers Cache drivers are configured globally in `config/doctrine.php` and can be extended or replaced via `CacheManager`. ```php // .env — switch all caches to Redis for production DOCTRINE_CACHE=redis DOCTRINE_METADATA_CACHE=redis DOCTRINE_QUERY_CACHE=redis DOCTRINE_RESULT_CACHE=redis ``` ```php // app/Providers/AppServiceProvider.php — add a custom cache driver use LaravelDoctrine\ORM\Configuration\Cache\CacheManager; use Illuminate\Contracts\Foundation\Application; public function register(): void { $this->app->resolving(CacheManager::class, function (CacheManager $cache, Application $app) { $cache->extend('my_apcu', function (array $settings, Application $app) { return new \Symfony\Component\Cache\Adapter\ApcuAdapter( namespace: $settings['namespace'] ?? '', defaultLifetime: 0, ); }); }); } ``` --- ## Connections ### Extending Connection Drivers The `ConnectionManager` resolves Laravel's `config/database.php` connections into Doctrine DBAL parameters and supports custom drivers. ```php // app/Providers/AppServiceProvider.php use LaravelDoctrine\ORM\Configuration\Connections\ConnectionManager; use Illuminate\Contracts\Container\Container; public function boot(ConnectionManager $connections): void { $connections->extend('cratedb', function (array $settings, Container $container): array { return [ 'driver' => 'pdo_pgsql', 'host' => $settings['host'] ?? '127.0.0.1', 'port' => $settings['port'] ?? 4200, 'dbname' => $settings['database'], 'user' => $settings['username'], 'password' => $settings['password'], ]; }); } ``` ### Read/Write (Primary/Replica) Connections ```php // config/database.php 'mysql' => [ 'driver' => 'mysql', 'read' => [ ['host' => env('DB_READ_HOST_1', '192.168.1.2')], ['host' => env('DB_READ_HOST_2', '192.168.1.3')], ], 'write' => [ 'host' => env('DB_HOST', '192.168.1.1'), ], 'database' => env('DB_DATABASE', 'myapp'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8mb4', 'defaultTableOptions' => [ 'charset' => 'utf8mb4', 'collate' => 'utf8mb4_unicode_ci', ], ], ``` --- ## Artisan Console Commands ### Schema and Cache Management Commands ```bash # Create the database schema from entity mappings php artisan doctrine:schema:create # Update the schema to match current entity mappings (shows SQL diff) php artisan doctrine:schema:update # Drop the entire schema php artisan doctrine:schema:drop # Validate entity mapping files against the database schema php artisan doctrine:schema:validate # Generate proxy classes for all entities (required in production before deploy) php artisan doctrine:generate:proxies # Show all mapped entities and their metadata php artisan doctrine:info # Clear individual caches php artisan doctrine:clear:metadata:cache php artisan doctrine:clear:query:cache php artisan doctrine:clear:result:cache ``` --- ## Summary Laravel Doctrine ORM is best suited for applications where strict domain-driven design principles matter: complex business domains that benefit from entities isolated from persistence logic, multi-database architectures requiring separate entity managers per connection, and teams migrating from other frameworks already familiar with Doctrine. Its deep integration with Laravel's service container, authentication, validation, pagination, notification, and queue systems means it can act as a full drop-in ORM replacement for Eloquent without losing Laravel's productivity features. The library's extension system and `DoctrineManager` make it easy to introduce behavioral extensions (soft-deletes, timestamps, sluggable, ACL) or advanced DBAL configuration without touching core code. For integration, the typical pattern is: publish and edit `config/doctrine.php` to point at entity paths and configure connections; define plain PHP entity classes with attribute (or XML) metadata; bind repository interfaces to Doctrine repository implementations in a service provider; and access the `EntityManager` via constructor injection or the `EntityManager` facade. All Laravel features — `Auth::attempt()`, `$request->validate()`, `$model->notify()`, `->paginate()` — work transparently against Doctrine entities once the appropriate provider swaps (`doctrine` auth driver, `PasswordResetServiceProvider`, `DoctrineChannel`) are applied.