Doctrine ORM
This guide provides a comprehensive overview of how to use Doctrine ORM (Object-Relational Mapping) with the ODY framework, enabling efficient entity mapping and management.
Overview
The ODY Database module’s Doctrine ORM integration provides:
- Coroutine-aware entity manager with connection pooling
- Support for PHP 8 attributes for entity mapping
- Automated transaction and connection management
- Repository pattern implementation for clean data access
Installation
composer require ody/database doctrine/orm doctrine/dbal symfony/cache
Configuration
Service Provider Registration
Register the required service providers in your application’s providers configuration:
'providers' => [
// Base providers
Ody\DB\Providers\DatabaseServiceProvider::class,
Ody\DB\Doctrine\Providers\DBALServiceProvider::class,
Ody\DB\Doctrine\Providers\DoctrineORMServiceProvider::class,
// Your custom providers
App\Providers\RepositoryServiceProvider::class,
App\Providers\DoctrineIntegrationProvider::class,
],
Database Configuration
return [
'charset' => 'utf8mb4',
'environments' => [
'local' => [
'adapter' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 3306),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', 'root'),
'db_name' => env('DB_DATABASE', 'ody'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_general_ci',
'prefix' => '',
'options' => [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_CASE,
PDO::CASE_LOWER,
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
PDO::MYSQL_ATTR_DIRECT_QUERY => false,
],
'pool' => [
'enabled' => env('DB_ENABLE_POOL', false),
'pool_name' => env('DB_POOL_NAME', 'default'),
'connections_per_worker' => env('DB_POOL_CONN_PER_WORKER', 10),
'minimum_idle' => 128,
'idle_timeout' => 60.0,
'max_lifetime' => 3600.0,
'borrowing_timeout' => 2,
'returning_timeout' => 1,
'leak_detection_threshold' => 10.0,
'keep_alive_check_interval' => 60.0,
]
],
'production' => [
// Production-specific configuration...
'pool' => [
'enabled' => env('DB_ENABLE_POOL', false),
'connections_per_worker' => 64,
'minimum_idle' => 32,
// Other pool settings...
]
],
],
'default_environment' => 'local',
// Other configuration settings...
];
Doctrine Configuration
Create or modify your config/doctrine.php
file:
return [
'entity_paths' => [
base_path('app/Entities'),
],
'proxy_dir' => storage_path('proxies'),
'naming_strategy' => \Doctrine\ORM\Mapping\UnderscoreNamingStrategy::class,
'cache' => [
// Cache type: array, file, redis
'type' => env('DOCTRINE_CACHE_TYPE', 'array'),
// TTL for file and redis cache (in seconds)
'ttl' => env('DOCTRINE_CACHE_TTL', 3600),
// Directory for file cache
'directory' => storage_path('cache/doctrine'),
],
'types' => [
'json' => \Ody\DB\Doctrine\Types\JsonType::class,
],
'enable_events' => env('DOCTRINE_ENABLE_EVENTS', true),
'event_subscribers' => [
// Add your custom event subscribers here
],
];
Implementing Required Components
1. Database Middleware
The Database Middleware manages database connections for HTTP requests:
<?php
namespace App\Http\Middleware;
use Closure;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* Middleware for handling database connections in HTTP requests
*/
class DatabaseMiddleware implements MiddlewareInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private LoggerInterface $logger
) {}
/**
* Handle the incoming request and ensure database connections are properly managed.
*
* @param ServerRequestInterface $request
* @param RequestHandlerInterface $handler
* @return ResponseInterface
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
// Initialize the EntityManager for this request
$em = $this->entityManager;
// Handle the request
try {
$response = $handler->handle($request);
// Ensure all active transactions are committed
if ($em->getConnection()->isTransactionActive()) {
$this->logger->warning("Transaction active at end of request - committing automatically");
$em->getConnection()->commit();
}
return $response;
} catch (Throwable $e) {
// Roll back any open transactions on error
if ($em->getConnection()->isTransactionActive()) {
try {
$em->getConnection()->rollBack();
} catch (Throwable $rollbackException) {
$this->logger->error("Failed to rollback transaction: " . $rollbackException->getMessage());
}
}
throw $e;
}
}
/**
* PSR-15 middleware compatibility for frameworks that use Closure-based middleware
*
* @param ServerRequestInterface $request
* @param Closure $next
* @return ResponseInterface
* @throws Throwable
*/
public function handle(ServerRequestInterface $request, Closure $next): ResponseInterface
{
return $this->process($request, new class($next) implements RequestHandlerInterface {
private $next;
public function __construct(Closure $next)
{
$this->next = $next;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return ($this->next)($request);
}
});
}
}
2. Doctrine Integration Provider
The Doctrine Integration Provider registers custom types and middleware:
<?php
namespace App\Providers;
use App\Http\Middleware\DatabaseMiddleware;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use Ody\DB\Doctrine\Types\JsonType;
use Ody\Foundation\Providers\ServiceProvider;
use Psr\Log\LoggerInterface;
class DoctrineIntegrationProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Register the database middleware
$this->container->singleton(DatabaseMiddleware::class, function ($container) {
return new DatabaseMiddleware(
$container->get(EntityManagerInterface::class),
$container->get(LoggerInterface::class)
);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Register custom Doctrine types
$this->registerDoctrineTypes();
// Add middleware to global middleware stack
$this->registerMiddleware();
}
/**
* Register custom Doctrine types
*/
protected function registerDoctrineTypes(): void
{
try {
// Check if the JsonType is already registered
if (!Type::hasType('json')) {
Type::addType('json', JsonType::class);
}
// Register more custom types here as needed
} catch (\Throwable $e) {
logger()->error('Failed to register Doctrine types: ' . $e->getMessage());
}
}
/**
* Register the database middleware
*/
protected function registerMiddleware(): void
{
// Add the middleware to the global middleware stack if your framework supports it
if (method_exists($this->container, 'middleware') && method_exists($this->container->middleware(), 'push')) {
$this->container->middleware()->push(DatabaseMiddleware::class);
}
}
}
3. Repository Service Provider
The Repository Service Provider registers your custom repositories:
<?php
namespace App\Providers;
use App\Repositories\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Ody\Foundation\Providers\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register user repository
$this->container->bind(UserRepository::class, function ($container) {
return new UserRepository(
$container->make(EntityManagerInterface::class)
);
});
// Register other repositories as needed
}
public function boot(): void
{
// Boot logic
}
}
Creating Entities
Create your entity classes in the app/Entities
or in a custom defined directory using PHP 8 attributes:
<?php
namespace App\Entities;
use Doctrine\ORM\Mapping as ORM;
use DateTime;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
#[ORM\HasLifecycleCallbacks]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $email;
#[ORM\Column(name: 'created_at', type: 'datetime')]
private DateTime $createdAt;
#[ORM\Column(name: 'updated_at', type: 'datetime', nullable: true)]
private ?DateTime $updatedAt = null;
// Relationships
#[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'user')]
private Collection $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
$this->createdAt = new DateTime();
}
// Getters and setters
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getCreatedAt(): DateTime
{
return $this->createdAt;
}
public function getUpdatedAt(): ?DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(?DateTime $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getPosts(): Collection
{
return $this->posts;
}
// Lifecycle callbacks
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTime();
}
}
Creating Repositories
Create custom repositories by extending the BaseRepository class:
<?php
namespace App\Repositories;
use App\Entities\User;
use Ody\DB\Doctrine\Repository\BaseRepository;
use Doctrine\ORM\EntityManagerInterface;
class UserRepository extends BaseRepository
{
protected string $entityClass = User::class;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct($entityManager);
}
/**
* Find a user by email address
*/
public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}
/**
* Find active users
*/
public function findActiveUsers(int $limit = 10): array
{
$qb = $this->createQueryBuilder('u')
->where('u.status = :status')
->setParameter('status', 'active')
->orderBy('u.name', 'ASC')
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
/**
* Find users with recent posts
*/
public function findUsersWithRecentPosts(\DateTime $since): array
{
$qb = $this->createQueryBuilder('u');
$qb->join('u.posts', 'p')
->where('p.createdAt >= :since')
->setParameter('since', $since)
->groupBy('u.id')
->orderBy('u.name', 'ASC');
return $qb->getQuery()->getResult();
}
}
Basic Usage
Using the EntityManager
<?php
namespace App\Http\Controllers;
use App\Entities\User;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class UserController
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function store(ServerRequestInterface $request): ResponseInterface
{
$data = $request->getParsedBody();
// Create a new entity
$user = new User();
$user->setName($data['name']);
$user->setEmail($data['email']);
// Persist and flush
$this->entityManager->persist($user);
$this->entityManager->flush();
return response()->json([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
], 201);
}
public function show(ServerRequestInterface $request, int $id): ResponseInterface
{
// Find an entity by ID
$user = $this->entityManager->find(User::class, $id);
if (!$user) {
return response()->json(['message' => 'User not found'], 404);
}
return response()->json([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
]);
}
public function update(ServerRequestInterface $request, int $id): ResponseInterface
{
$data = $request->getParsedBody();
// Find an entity by ID
$user = $this->entityManager->find(User::class, $id);
if (!$user) {
return response()->json(['message' => 'User not found'], 404);
}
// Update entity properties
if (isset($data['name'])) {
$user->setName($data['name']);
}
if (isset($data['email'])) {
$user->setEmail($data['email']);
}
// No need to call persist() for updates
$this->entityManager->flush();
return response()->json([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
]);
}
public function destroy(ServerRequestInterface $request, int $id): ResponseInterface
{
// Find an entity by ID
$user = $this->entityManager->find(User::class, $id);
if (!$user) {
return response()->json(['message' => 'User not found'], 404);
}
// Remove the entity
$this->entityManager->remove($user);
$this->entityManager->flush();
return response()->json(['message' => 'User deleted successfully']);
}
}
Using Repositories
<?php
namespace App\Http\Controllers;
use App\Repositories\UserRepository;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class UserApiController
{
public function __construct(
private UserRepository $userRepository
) {}
public function index(ServerRequestInterface $request): ResponseInterface
{
// Use repository methods to fetch entities
$users = $this->userRepository->findActiveUsers(20);
return response()->json([
'data' => array_map(fn($user) => [
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
], $users)
]);
}
public function findByEmail(ServerRequestInterface $request): ResponseInterface
{
$email = $request->getQueryParams()['email'] ?? '';
$user = $this->userRepository->findByEmail($email);
if (!$user) {
return response()->json(['message' => 'User not found'], 404);
}
return response()->json([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
]);
}
public function recentAuthors(ServerRequestInterface $request): ResponseInterface
{
$since = new \DateTime('-30 days');
$users = $this->userRepository->findUsersWithRecentPosts($since);
return response()->json([
'data' => array_map(fn($user) => [
'id' => $user->getId(),
'name' => $user->getName(),
'post_count' => $user->getPosts()->count()
], $users)
]);
}
public function save(ServerRequestInterface $request): ResponseInterface
{
$data = $request->getParsedBody();
$user = new \App\Entities\User();
$user->setName($data['name']);
$user->setEmail($data['email']);
// Use repository methods to save entities
$this->userRepository->save($user);
return response()->json([
'id' => $user->getId(),
'name' => $user->getName(),
'email' => $user->getEmail()
], 201);
}
}
Working with Transactions
Manual Transaction Management
$this->entityManager->beginTransaction();
try {
// Perform multiple database operations
$user = new User();
$user->setName('John Doe');
$user->setEmail('john@example.com');
$this->entityManager->persist($user);
$post = new Post();
$post->setTitle('My First Post');
$post->setContent('Hello world!');
$post->setUser($user);
$this->entityManager->persist($post);
$this->entityManager->flush();
$this->entityManager->commit();
return response()->json(['message' => 'User and post created successfully']);
} catch (\Exception $e) {
$this->entityManager->rollBack();
return response()->json(['error' => 'Failed to create user and post: ' . $e->getMessage()], 500);
}
Using the Transactional Helper
$result = $this->entityManager->transactional(function($em) use ($userData, $postData) {
$user = new User();
$user->setName($userData['name']);
$user->setEmail($userData['email']);
$em->persist($user);
$post = new Post();
$post->setTitle($postData['title']);
$post->setContent($postData['content']);
$post->setUser($user);
$em->persist($post);
// No need to call flush, it's done automatically
return [
'userId' => $user->getId(),
'postId' => $post->getId()
];
});
return response()->json($result);
Advanced Features
Coroutine Operations
The ODY Doctrine integration provides a trait for coroutine operations:
<?php
namespace App\Services;
use App\Entities\User;
use Doctrine\ORM\EntityManagerInterface;
use Ody\DB\Doctrine\Traits\CoroutineEntityManagerTrait;
class UserService
{
use CoroutineEntityManagerTrait;
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* Process multiple users in parallel
*/
public function processUsers(array $userIds): array
{
return $this->parallelEntityManagerOperations($userIds, function($userId, $em) {
$user = $em->find(User::class, $userId);
if (!$user) {
return ['userId' => $userId, 'status' => 'not_found'];
}
// Process user data
$result = $this->calculateUserScore($user);
// Update user
$user->setScore($result['score']);
$em->flush();
return [
'userId' => $userId,
'status' => 'processed',
'score' => $result['score']
];
});
}
/**
* Perform a transaction within a dedicated coroutine
*/
public function createUserInBackground(array $userData): void
{
$this->transactionalCoroutine(function($em) use ($userData) {
$user = new User();
$user->setName($userData['name']);
$user->setEmail($userData['email']);
$em->persist($user);
// No need to call flush in transactional callback
logger()->info("Created user {$user->getId()} in background coroutine");
});
}
/**
* Example calculation method
*/
private function calculateUserScore(User $user): array
{
// Simulate complex calculation
return [
'score' => rand(1, 100)
];
}
}
Custom Event Subscribers
Create event subscribers to hook into Doctrine’s lifecycle events:
<?php
namespace App\Doctrine\Events;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use App\Entities\User;
use Psr\Log\LoggerInterface;
class UserEventSubscriber implements EventSubscriber
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
/**
* {@inheritdoc}
*/
public function getSubscribedEvents(): array
{
return [
Events::postPersist,
Events::preUpdate,
Events::postRemove,
];
}
/**
* Called after a new entity is persisted
*/
public function postPersist(LifecycleEventArgs $args): void
{
$entity = $args->getObject();
if ($entity instanceof User) {
$this->logger->info("User created: {$entity->getName()} (ID: {$entity->getId()})");
// Perform additional actions like sending welcome email, etc.
}
}
/**
* Called before an entity is updated
*/
public function preUpdate(LifecycleEventArgs $args): void
{
$entity = $args->getObject();
if ($entity instanceof User) {
// Set updated timestamp if not already set
if (!$entity->getUpdatedAt()) {
$entity->setUpdatedAt(new \DateTime());
}
}
}
/**
* Called after an entity is removed
*/
public function postRemove(LifecycleEventArgs $args): void
{
$entity = $args->getObject();
if ($entity instanceof User) {
$this->logger->info("User deleted: {$entity->getName()} (ID: {$entity->getId()})");
// Perform cleanup actions
}
}
}
Register the subscriber in your config/doctrine.php
file:
'event_subscribers' => [
App\Doctrine\Events\UserEventSubscriber::class,
],
Schema Management
You can use Doctrine’s schema management tools to create and update your database schema:
<?php
namespace App\Console\Commands;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Ody\Foundation\Console\Command;
class UpdateDatabaseSchema extends Command
{
protected $signature = 'doctrine:schema:update {--force : Force schema update}';
protected $description = 'Update database schema from entity definitions';
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct();
$this->entityManager = $entityManager;
}
public function handle(): int
{
$metadataFactory = $this->entityManager->getMetadataFactory();
$schemaTool = new SchemaTool($this->entityManager);
$force = $this->option('force') === true;
$classMetadata = $metadataFactory->getAllMetadata();
if (empty($classMetadata)) {
$this->error('No entity metadata found. Did you create your entity classes?');
return 1;
}
$this->info('Updating database schema...');
if ($force) {
$schemaTool->updateSchema($classMetadata);
$this->info('Database schema updated successfully!');
} else {
$sql = $schemaTool->getUpdateSchemaSql($classMetadata);
if (empty($sql)) {
$this->info('Nothing to update - your database is already in sync with the current entity metadata.');
return 0;
}
$this->info(sprintf('Database schema update SQL statements (%d statements):', count($sql)));
$this->newLine();
foreach ($sql as $query) {
$this->line($query);
}
$this->newLine();
$this->info('To execute these statements, re-run this command with the --force option.');
}
return 0;
}
}
Performance Optimization
Entity Manager Clearing
For long-running processes or batch operations, clear the EntityManager periodically to free memory:
public function processBatch(array $items): void
{
foreach ($items as $i => $item) {
$entity = $this->entityManager->find(SomeEntity::class, $item['id']);
// Process entity
$entity->setSomeProperty($item['value']);
// Flush periodically
if ($i % 100 === 0) {
$this->entityManager->flush();
$this->entityManager->clear(); // Clear managed entities
// Re-fetch entities that you still need after clearing
}
}
// Final flush for remaining entities
$this->entityManager->flush();
}
Query Result Iteration
For large result sets, use iterators to process entities in batches:
$query = $this->entityManager->createQuery('SELECT u FROM App\Entities\User u');
$query->setHint(\Doctrine\ORM\Query::HINT_FORCE_PARTIAL_LOAD, true);
$iterableResult = $query->iterate();
$batchSize = 100;
$i = 0;
foreach ($iterableResult as $row) {
$user = $row[0];
// Process user
$user->setSomeProperty('value');
if (($i % $batchSize) === 0) {
$this->entityManager->flush();
$this->entityManager->clear();
}
++$i;
}
$this->entityManager->flush();
Optimizing Entity Hydration
For read-only operations, use custom hydration modes to improve performance:
// Array hydration (faster than object hydration)
$query = $this->entityManager->createQuery('SELECT u FROM App\Entities\User u');
$results = $query->getResult(\Doctrine\ORM\Query::HYDRATE_ARRAY);
// Scalar results (even faster for simple data)
$query = $this->entityManager->createQuery('SELECT u.id, u.name FROM App\Entities\User u');
$results = $query->getResult(\Doctrine\ORM\Query::HYDRATE_SCALAR);
// Custom object hydration
$query = $this->entityManager->createQuery('SELECT u.id, u.name, u.email FROM App\Entities\User u');
$results = $query->getResult(\Doctrine\ORM\Query::HYDRATE_OBJECT);
Troubleshooting
Connection Pool Issues
If you experience connection pool-related errors:
- Check your pool configuration in
config/database.php
- Ensure your
keep_alive_check_interval
is less than the MySQLwait_timeout
- Adjust
connections_per_worker
andminimum_idle
based on your application load
Transaction Management
If transactions are not being properly committed or rolled back:
- Use the
DatabaseMiddleware
provided in this guide - Ensure all database operations happen within the same coroutine
- Always use try/catch blocks with proper rollback logic
- Consider using the
transactional
helper method for simpler management
Entity Lifecycle
If you encounter issues with entity states or lifecycle events:
- Be aware of the different entity states (new, managed, detached, removed)
- Remember that
clear()
detaches all entities from the EntityManager - Lifecycle callbacks require the
#[ORM\HasLifecycleCallbacks]
attribute on the entity class - Use entity repositories for consistent entity management
For more details on specific Doctrine ORM features, refer to the official Doctrine documentation.
License
This package is open-sourced software licensed under the MIT license.