Integrating Doctrine ORM into your Laravel 12 project provides powerful object-relational mapping and flexible database management. By leveraging the APCu cache, you can dramatically boost performance for metadata and query caching. Here’s a step-by-step guide to a clean, maintainable, and high-performance setup.
1. Required Packages
First, install the essential packages via Composer:
composer require symfony/cache doctrine/orm doctrine/dbal
- symfony/cache: Modern PSR-6/PSR-16 cache support;
- doctrine/orm: Core ORM functionality;
- doctrine/dbal: Database abstraction layer.
APCu PHP Extension:
Make sure the APCu extension is installed and enabled in your PHP environment. You can check this with:
php -m | grep apcu
If not installed, add it (for example, with pecl install apcu) and enable it in your php.ini:
extension=apcu.so
2. Why Use APCu?
Doctrine ORM benefits greatly from caching:
- Metadata cache: Stores class mapping info, reducing parsing overhead;
- Query cache: Caches DQL parsing for faster query execution.
APCu is an in-memory cache, making it extremely fast and ideal for single-server setups. It reduces database and CPU load, resulting in improved response times.
3. Example: Custom EntityManager Service Provider
Create a custom Laravel service provider to configure Doctrine ORM and APCu caching.
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Doctrine\ORM\ORMSetup;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\DB;
final class DoctrineServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(
abstract: EntityManagerInterface::class,
concrete: function (Application $app): EntityManager {
$config = ORMSetup::createAttributeMetadataConfiguration(
paths: config(key: 'doctrine.metadata_dirs'),
isDevMode: config(key: 'doctrine.dev_mode'),
);
$connection = DriverManager::getConnection(
params: config(key: 'doctrine.connection'),
config: $config
);
foreach (config(key: 'doctrine.custom_types') as $name => $className) {
if (!Type::hasType(name: $name)) {
Type::addType(name: $name, className: $className);
}
}
return new EntityManager(conn: $connection, config: $config);
}
);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
DB::connection()->getPdo()->exec("SET NAMES 'UTF8'");
}
}
Register this provider in your bootstrap/providers.php
providers array.
4. Doctrine Configuration (config/doctrine.php)
Set up your connection and Doctrine settings:
declare(strict_types=1);
return [
'connection' => [
'driver' => 'pdo_pgsql',
'host' => env('DB_HOST', 'postgres'),
'port' => env('DB_PORT', '5432'),
'dbname' => env('DB_DATABASE', 'database'),
'user' => env('DB_USERNAME', 'user'),
'password' => env('DB_PASSWORD', 'password'),
'options' => [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
],
],
'metadata_dirs' => [
app_path('Entities'),
],
'custom_types' => [],
'dev_mode' => env('APP_ENV') === 'dev',
];
5. Summary Checklist
- Install symfony/cache, doctrine/orm, and doctrine/dbal via Composer;
- Enable the APCu PHP extension for high-speed in-memory caching;
- Register a custom service provider to configure and instantiate Doctrine’s EntityManager;
- Define your Doctrine configuration in config/doctrine.php, including connection details and metadata directories.
6. Performance Tips
- APCu is best for single-server setups. For distributed systems, consider Redis or Memcached;
- Use APCu for metadata and query cache. For result cache, you might want to use Redis for persistence;
- Keep your APCu cache size and TTL (time-to-live) in check to avoid cache fragmentation.
7. Example of an entity
=#[ORM\Entity]
#[ORM\Table(name: 'users')]
final class User extends AggregateRoot implements JWTSubject, AuthenticatableContract
{
/**
* Provides authentication methods for the user.
*/
use Authenticatable;
/**
* Automatically manages created_at and updated_at timestamps.
*/
use CreatedDateProvider;
use UpdatedDateProvider;
/**
* Contains setters and mutator methods for user data management.
*/
use DataMutator;
/**
* Unique identifier for the user.
*
* @var UserId
*/
#[ORM\Id]
#[ORM\Column(name: 'id', type: UserId::class, unique: true)]
public private(set) UserId $id;
/**
* The user's avatar file path.
*
* @var Avatar
*/
#[Assert\Valid]
#[ORM\Embedded(class: Avatar::class, columnPrefix: false)]
public private(set) ?Avatar $avatar;
/**
* The user's first name.
*
* @var string
*/
#[Assert\NotBlank(message: 'First name must not be empty.')]
#[Assert\Length(
min: 2,
max: 18,
minMessage: 'First name must be at least {{ limit }} characters long.',
maxMessage: 'First name cannot be longer than {{ limit }} characters.'
)]
#[ORM\Column(name: 'first_name', type: Types::STRING, length: 18)]
public private(set) string $firstName {
set (string $value) {
$value = trim(string: $value);
$value = mb_convert_case(string: $value, mode: MB_CASE_TITLE);
$this->firstName = $value;
}
}
/**
* The user's last name.
*
* @var string
*/
#[Assert\NotBlank(message: 'Last name must not be empty.')]
#[Assert\Length(
min: 2,
max: 27,
minMessage: 'Last name must be at least {{ limit }} characters long.',
maxMessage: 'Last name cannot be longer than {{ limit }} characters.'
)]
#[ORM\Column(name: 'last_name', type: Types::STRING, length: 27)]
public private(set) ?string $lastName {
set (?string $value) => $this->lastName = $value !== null
? trim(string: mb_convert_case(string: $value, mode: MB_CASE_TITLE))
: null;
}
/**
* The user's email address.
*
* @var Email
*/
#[Assert\Valid]
#[ORM\Embedded(class: Email::class, columnPrefix: false)]
public private(set) Email $email;
/**
* The timestamp when the email was verified.
*
* @var \DateTime|null
*/
#[ORM\Column(name: 'email_verified_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $emailVerifiedAt;
/**
* The token used for "remember me" functionality.
*
* @var string|null
*/
#[ORM\Column(name: 'remember_token', type: Types::STRING, length: 100, unique: true, nullable: true)]
private ?string $rememberToken = null {
set (?string $value) => $this->rememberToken = $value !== null ? trim(string: $value) : null;
}
/**
* The user's password.
*
* @var Password
*/
#[Assert\Valid]
#[ORM\Embedded(class: Password::class, columnPrefix: false)]
public private(set) Password $password;
/**
* The user's status (active/inactive).
*
* @var bool
*/
#[Assert\NotNull(message: 'Status must not be null.')]
#[ORM\Column(name: 'status', type: Types::BOOLEAN)]
public private(set) bool $status;
/**
* The role of the user.
*
* @var RoleId|null
*/
#[Assert\Uuid(message: 'Role ID must be a valid UUID.', groups: ['Default'])]
#[ORM\Column(name: 'role_id', type: RoleId::class, nullable: true)]
public private(set) ?RoleId $roleId;
/**
* Initializes a new user with the given details.
*
* @param Avatar|null $avatar
* @param string $firstName
* @param string|null $lastName
* @param Email $email
* @param Password $password
* @param bool $status
* @param RoleId|null $roleId
* @param UserId|null $id
*/
public function __construct(
?Avatar $avatar = null,
string $firstName,
?string $lastName = null,
Email $email,
Password $password,
bool $status = true,
?RoleId $roleId = null,
?UserId $id = null
) {
/**
* Generates a new user ID if none is provided.
*/
$this->id = $id ?? UserId::generate();
/**
* Assigns user personal and account details.
*/
$this->avatar = $avatar;
$this->firstName = $this->firstName;
$this->lastName = $this->lastName;
$this->email = $this->email;
$this->password = $password;
$this->status = $status;
/**
* Sets the role identifier for the user.
*/
$this->roleId = $roleId;
}
/**
* Get the identifier that will be stored in the JWT subject claim.
*
* @return string
*/
public function getJWTIdentifier(): string
{
return $this->id->toString();
}
/**
* Get the custom claims to be added to the JWT.
*
* @return array
*/
public function getJWTCustomClaims(): array
{
return [
'id' => $this->id->asString(),
'email' => (string) $this->email,
];
}
}
8. Examples of queries
final class DoctrineRepository extends StorageRepository
{
/**
* Constructs a new DoctrineRepository instance.
*
* @param EntityManagerInterface $entityManager
*/
public function __construct(
private EntityManagerInterface $entityManager
) {}
/**
* Retrieves all users ordered by creation date in descending order.
*
* @return array
*/
public function all(): array
{
return $this->entityManager->getRepository(
className: User::class
)->findBy(
criteria: [],
orderBy: ['createdAt' => 'DESC']
);
}
/**
* Finds a user by ID in the database.
*
* @param UserId $id
* @return User|null
*/
public function findById(UserId $id): ?User
{
return $this->entityManager->getRepository(
className: User::class
)->find(
id: $id
);
}
/**
* Saves a user to the database.
*
* @param User $user
*/
public function save(User $user): void
{
try {
$this->entityManager->persist(object: $user);
$this->entityManager->flush();
}
catch (ORMException $e) {
Log::error(
message: 'User creation failed: ' . $e->getMessage(),
context: [
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine()
]
);
throw new \RuntimeException(
message: "Failed to save user: {$e->getMessage()}",
code: (int) $e->getCode(),
previous: $e
);
}
}
/**
* Removes a user from the database.
*
* @param User $user
*/
public function remove(User $user): void
{
try {
$this->entityManager->remove(object: $user);
$this->entityManager->flush();
}
catch (ORMException $e) {
Log::error(
message: 'User deletion failed: ' . $e->getMessage(),
context: [
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine()
]
);
throw new \RuntimeException(
message: "Failed to delete user: {$e->getMessage()}",
code: (int) $e->getCode(),
previous: $e
);
}
}
}
Conclusion
This setup brings the power and flexibility of Doctrine ORM to your Laravel 12 application, with APCu providing a significant performance boost for metadata and query caching. The result is a clean, maintainable, and high-performing integration, ready for even demanding workloads.
Happy coding!
P. S. Subscribe to my Telegram channel - @initialstack where I share insights on structuring and architecting PHP applications with the Laravel framework.