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.