doctrine/phpcr-odm
Doctrine PHPCR ODM maps PHP objects to a PHPCR content repository, bringing Doctrine-style object-document mapping, unit of work, and repositories to PHP content management systems. It supports hierarchical, versioned content storage and integrates with Jackrabbit and Doctrine DBAL implementations.
1. Pick a PHPCR implementation first. The ODM is a mapper — it needs a repository behind it. For a Laravel app the two realistic choices are:
# SQL-backed (easier ops, works with MySQL/Postgres, good enough for small/medium trees)
composer require doctrine/phpcr-odm jackalope/jackalope-doctrine-dbal
# Jackrabbit (full JCR: workspaces, versioning, observation — but a Java service to run)
composer require doctrine/phpcr-odm jackalope/jackalope-jackrabbit
If you are unsure, start with jackalope-doctrine-dbal. You can swap later — that portability is the whole point of PHPCR — but versioning and workspaces are much better implemented on Jackrabbit.
2. Bootstrap the DocumentManager once, in a service provider. Don't build it per-request; it holds the identity map and the PHPCR session. In Laravel, bind it as a singleton and dispose it at the end of the request/worker:
// app/Providers/PhpcrServiceProvider.php
public function register(): void
{
$this->app->singleton(DocumentManager::class, function () {
$factory = new \Jackalope\RepositoryFactoryDoctrineDBAL();
$repository = $factory->getRepository([
'jackalope.doctrine_dbal_connection' => $this->laravelToDbalConnection(),
]);
$session = $repository->login(
new \PHPCR\SimpleCredentials(config('phpcr.user'), config('phpcr.pass')),
config('phpcr.workspace', 'default')
);
$config = new \Doctrine\ODM\PHPCR\Configuration();
$config->setMetadataDriverImpl(
new \Doctrine\ODM\PHPCR\Mapping\Driver\AttributeDriver([app_path('Documents')])
);
$config->setMetadataCache(new \Symfony\Component\Cache\Adapter\ArrayAdapter());
return \Doctrine\ODM\PHPCR\DocumentManager::create($session, $config);
});
}
Note that Jackalope wants a raw Doctrine DBAL connection, not an Eloquent connection — you can build one from your existing config, but they are separate objects with separate lifetimes.
3. Write one document and read it back. Attributes only — annotations are gone as of 2.0:
namespace App\Documents;
use Doctrine\ODM\PHPCR\Mapping\Attributes as PHPCR;
use Doctrine\ODM\PHPCR\DocumentRepository;
#[PHPCR\Document(repositoryClass: PageRepository::class, versionable: true)]
class Page
{
#[PHPCR\Id]
public ?string $id = null; // path or uuid
#[PHPCR\Nodename]
public ?string $nodename = null; // local name, e.g. "about-us"
#[PHPCR\ParentDocument]
public ?Page $parent = null;
#[PHPCR\Children]
public $children;
#[PHPCR\Field(type: 'string')]
public ?string $title = null;
#[PHPCR\Field(type: 'datetime')]
public ?\DateTimeInterface $publishedAt = null;
}
4. First real use case: build a page tree.
$home = new Page();
$home->nodename = 'home';
$home->title = 'Home';
$dm->persist($home); // roots need a nodename; children need a parent set
$about = new Page();
$about->nodename = 'about';
$about->title = 'About us';
$about->parent = $home; // #[ParentDocument] drives placement
$dm->persist($about);
$dm->flush(); // one round trip for the whole unit of work
$dm->clear(); // after you are done with this unit of work
Where to look first: the Doctrine PHPCR-ODM docs on doctrine-project.org (documents, mapping, query-builder, events), then DocumentManager and Doctrine\ODM\PHPCR\Mapping\Attributes\* in vendor. Read DocumentRepository before writing any repository of your own.
The value here is that parent, children, and node names are first-class mappings. Use them:
#[ParentDocument] + #[Nodename] for the addressable tree.#[Child] for a strict single-child relationship (e.g. a Page has one SeoBlock).#[Children] for the lazy ChildrenCollection — but treat it as a collection of node names, not an eager list. Use ->slice($offset, $limit) (not $offsetByNodeName; that is sliceByChildName() since 2.0) for paginated navigation.#[MappedSuperclass] for shared fields (title, slug, status) across Page, Article, Folder.Enforce shape at flush time with listeners rather than fighting the mapping: a Folder that must only contain Page is easier validated in prePersist/preUpdate than expressed in metadata.
DocumentRepository gives you find, findBy, findOneBy, findManyBy, findAll, findByPath. Anything that shows up twice becomes a repository method:
class PageRepository extends DocumentRepository
{
/** @return Page[] */
public function findPublished(): array
{
return $this->findBy([], ['publishedAt' => 'DESC']); // or a QOM query
}
public function findRoots(): array
{
return $this->findBy(['parent' => null]);
}
}
Register it via #[Document(repositoryClass: PageRepository::class)] — never instantiate DocumentRepository by hand.
PHPCR QOM has no DQL string. Build queries with $dm->createQueryBuilder()->fromDocument(...)->where()->... and hide the verbosity behind repository methods:
$qb = $dm->createQueryBuilder();
$qb->fromDocument(Page::class, 'p')
->where()->descendant('p', '/content/site')->andX()
->eq()->field('p.status')->literal('published')->end();
$pages = $qb->getQuery()->execute();
Two rules that keep this sane: (a) always alias the document explicitly, and (b) always ->setMaxResults() on anything that could return a subtree — the tree is unbounded, your response is not. For ad-hoc reporting, drop to $session->createQuery() with SQL2 and accept un-hydrated rows; just don't let that style leak into everyday code.
The day-to-day loop is find → mutate object → flush. Avoid touching the PHPCR Node directly, because then the ODM doesn't know about it. When you do need node-level operations, use the ODM wrappers:
$dm->move($page, $newParent); // subtree move, keeps identity
$dm->reorder($page, $beforePage); // sibling ordering
$dm->remove($page); // cascades per metadata
$dm->flush();
One flush per request where possible. Batch import work goes persist/100/flush/clear in a loop, not persist/100000/flush.
#[ReferenceOne(strategy: 'weak')] — safe default for content cross-links (author, related pages). Target removed → reference reads null.'hard' — the repository enforces integrity; deleting the target throws until you fix referrers. Correct for structural links, painful for editorial links.'path' — no integrity at all; cheapest, and the right one for "this refers to a path that may be recreated later."#[ReferenceMany] is the same trade-off, and ReferencesMany collections are lazy — hydrate them deliberately in list screens, not accidentally inside a foreach.
Add #[Locale] on a document and choose a translation strategy at the metadata level (child nodes per locale, or attribute per locale). Pick one strategy per document type and stay there — a mixed repository is the #1 source of "my content is missing in de" bugs. Versioning is driven by the ODM's version helpers over the workspace version manager; check a node in after each editorial publish rather than on every flush.
DocumentManager behind your own small ContentRepository interface (find/children/move/publish). This is the seam that protects you from the package's BC-break cadence and from clear()-related surprises in jobs.clear() the DocumentManager at the end of handle(); otherwise the identity map grows monotonically and you'll serve stale documents after another process edits them.terminate()/middleware to close the session — a long-lived session holding locks is how content editing suddenly "hangs" for everyone else.flush() does not save the session when the ODM saw no changes. Since 2.0, if you changed PHPCR nodes directly (or a listener did), flush() is a no-op and your writes silently vanish. Call $session->save() yourself in those cases.
Queries don't see your pending changes. The query builder executes against the PHPCR session, so persist() without flush() and then querying gives you the old tree. Flush before querying, or you'll write "it works sometimes" bug reports.
Identity map staleness is the main production bug class. Two workers editing the same subtree will both hold documents in memory. $dm->clear() between units of work, and use $dm->getDocumentId($doc) (added in 2.0) rather than reading $doc->id when you need a reliable path/UUID — the property may be unmapped or unset on new documents.
ChildrenCollection is lazy and deceptively cheap. count($page->children) may walk the tree. In listings, do the children as a single query rather than iterating nodes, and never iterate children inside a loop over parents.
New documents need a home. A document without a parent and without a nodename fails at flush with a confusing "no path" error. Set parent and nodename before persist(), and ensure the parent itself is persisted/flushed first when you generate a nested tree in one pass.
Nodename collisions throw at flush, not at assign. Validate/normalize slugs (Str::slug) before persist, and remember the node name is a JCR name: spaces, /, [, ], *, | are illegal or special.
Hard references bite at delete time. A remove() on a referenced target throws late in flush(). If you need cascading editorial deletes, clean the referrers in a preRemove listener or switch the reference to weak.
There are no migrations. Node types live in the repository and are registered via CND through the workspace node type manager — version that CND file in your repo like a schema, and apply it in deploy, idempotently. doctrine/migrations is not your tool here.
Do not reuse the Laravel DBAL connection for Jackalope. Sharing a Doctrine connection between the app's ORM/query builder and Jackalope's transaction handling produces deadlocks and half-saved trees. Give Jackalope its own connection config.
Metadata cache must be a PSR-6 adapter. Doctrine Cache is gone in 2.0; Configuration::setMetadataCache() takes a PSR-6 CacheItemPoolInterface. Use an array adapter in tests and a filesystem/redis adapter in production — parsing metadata on every find() is measurable on large trees.
Upgrade liability is real. Annotation mappings were removed (2.0), DocumentManagerInterface changed Collection → array and merge() was removed (3.0), query Expression classes moved, and ChildrenCollection::slice() no longer accepts a node name. Pin the major version, and keep all PHPCR usage behind your own interface so a future major is a contained change.
Debugging toolkit. $dm->getUnitOfWork() shows scheduled inserts/updates/deletes — check it before flush() when something isn't persisting. $session->getNode('/path') shows the raw repository state — check it after flush() when something isn't querying. Jackrabbit's log (jackrabbit/repository/log/) and the DBAL-backed SQL table contents both tell you whether the problem is the ODM or the transport.
Extension points worth knowing. Lifecycle events (prePersist, postPersist, preUpdate, preRemove, preFlush, postFlush, loadClassMetadata, onClear) registered on the Configuration's event manager are the sanctioned place for slug generation, timestamps, audit properties, and referrer cleanup. A custom AttributeDriver subclass or a hand-written metadata driver is the escape hatch if you need to derive mapping from config; a custom Field-adjacent type conversion is not really supported — transform in the document getter/setter instead.
Testing. Run integration tests against jackalope-doctrine-dbal (SQLite in-memory keeps them fast) and give each test a fresh workspace fixture with $dm->clear() + repository reset in tearDown. Mock-based unit tests are near-worthless here — almost every bug is in the interaction between the unit of work and the repository.
How can I help you explore Laravel packages today?