ibexa/core-persistence
Part of Ibexa DXP, this package delivers the core persistence layer: database-backed storage implementations, SPI handlers, and data access abstractions that underpin content, user, and configuration management across the platform.
ibexa/core-persistence is not a library you install on its own — it is the low-level persistence half of Ibexa DXP, split out of the main monorepo. There is no standalone entry point, no facade, and no meaningful use outside an Ibexa installation.
Minimal steps
composer create-project ibexa/website-skeleton my-project # or: composer require ibexa/dxp
cd my-project && composer require ibexa/oss # OSS flavour (GPL-2.0 side of the dual license)
ibexa/core-persistence arrives transitively via ibexa/core. Do not add it to require manually — you will end up with a version constraint fighting ibexa/core on every composer update.composer show ibexa/core-persistence
ls vendor/ibexa/core-persistence/src
src/contracts/ — the SPI interfaces (Ibexa\Contracts\Core\Persistence\Content\Handler, ...\Content\Location\Handler, ...\Section\Handler, ...\User\Handler, …). These are the stable contracts; everything else is implementation detail.src/lib/Legacy/<Domain>/Handler.php — the concrete handler that implements each contract.src/lib/Legacy/<Domain>/Gateway/DoctrineDatabase.php — where the actual SQL lives.src/lib/Cache/ — the decorating handlers that sit between the API and the legacy layer.src/lib/Doctrine/ — connection/schema plumbing.First use case
The realistic day-one use is reading content in bulk without paying the full Public API hydration cost: an export job, a sitemap generator, an admin report, or a data-migration pre-check. Inject the SPI handler and pull content plus locations in two queries per batch instead of one repository call per object:
use Ibexa\Contracts\Core\Persistence\Content\Handler as ContentHandler;
use Ibexa\Contracts\Core\Persistence\Content\Location\Handler as LocationHandler;
final class ContentExport
{
public function __construct(
private ContentHandler $contents,
private LocationHandler $locations,
) {}
public function export(array $contentIds): array
{
$rows = [];
// one batched query, keyed by content id
foreach ($this->contents->loadContentList($contentIds, ['eng-GB']) as $id => $content) {
$mainLocation = null;
foreach ($this->locations->loadLocationsByContent($id) as $location) {
if ($location->main) { $mainLocation = $location; break; }
}
$rows[] = [
'id' => $content->versionInfo->contentInfo->id,
'remote_id' => $content->versionInfo->contentInfo->remoteId,
'name' => $content->versionInfo->names['eng-GB'] ?? '',
'path' => $mainLocation?->pathString,
'fields' => array_map(
static fn ($field) => $field->value->data,
$content->fields
),
];
}
return $rows;
}
}
Note the shape you get back: Content → versionInfo → contentInfo, and fields is an array keyed by field definition identifier, with values being Content\FieldValue objects (data, externalData, sortKey). That triple indirection is the single biggest source of confusion for newcomers — internalise it before writing anything else.
For anything that writes, use the Public API (Ibexa\Contracts\Core\Repository\ContentService etc.) instead. Reach for this package only when the API is measurably too slow or too coarse.
API-first, SPI-on-exception. The house rule: the Repository/API layer is the public contract (permissions, workflows, cache invalidation, events, signals). Persistence handlers bypass all of it. Restrict SPI usage to read-heavy batch jobs, admin tooling, and custom field type storage. If you find yourself writing content through Content\Handler::create in application code, you are almost certainly creating corrupted state.
Service decoration is the supported extension point. Rather than forking a handler, wrap it:
# config/services.yaml
App\Persistence\LoggingContentHandler:
decorates: Ibexa\Contracts\Core\Persistence\Content\Handler
arguments: ['@.inner', '@logger']
Be aware there is already a cache decorator in the chain. Use decoration_priority to decide whether you sit inside the cache (your code sees every call, including cached hits — cheap) or outside it (your code only sees cache misses — you see the real DB traffic). For profiling, you want to be inside; for auditing, outside.
Custom Field Type external storage. This is the package's main legitimate extension surface:
Storage class implementing Ibexa\Contracts\Core\FieldType\StorageGateway (delegates to your gateway).Ibexa\Core\Persistence\Legacy\Content\FieldValue\Converter\...-adjacent DoctrineDatabase, targeting your own table.Converter (Ibexa\Core\Persistence\Legacy\Content\FieldValue\Converter) translating FieldValue ⇄ data_text/data_int/data_float columns and, critically, computing sort_key_int / sort_key_string.ibexa.field_type, plus the gateway tag — these were ezpublish.* in 2.x/3.x and ibexa.* in 4.x, so copy the tag names out of ibexa/core's Resources/config for your exact version rather than from a blog post).Bulk / migration work. For content-level changes prefer ibexa/migrations (it drives the API and keeps caches, versions and locations correct). Use raw persistence only for reading legacy data during analysis, or for schema-level fixes that no API exposes.
Reporting and read models. The cleanest pattern is a dedicated read-only service that injects three or four SPI handlers (Content, Location, Section, ContentType) and never touches the entity manager directly. Keep it out of the request/response path — it is built for console commands and queue workers.
Testing. Write integration tests against a real database with fixtures loaded through the installer. Mocking these interfaces is viable (they are plain interfaces) but the mocks rarely behave like the DBAL mappers — especially around the string-vs-int coercion noted below — so reserve mocks for unit-testing your logic, not the data shape.
composer show reports NOASSERTION because the package is dual-licensed: Ibexa Business Use License or GPL-2.0. If you are not on an Ibexa DXP subscription you are on GPL-2.0, which is copyleft and will contaminate a proprietary codebase. This is the one gotcha that can sink a project late — settle it before you build on the SPI.ezcontentobject_tree is load-bearing. path_string, main_node_id, parent_node_id and depth must stay mutually consistent. Never UPDATE them by hand — go through Location\Handler (move/copy/hide) or you will orphan subtrees that survive every cache clear.Mapper classes cast on the way out, but anything you read straight from a gateway returns strings. Type-hint int in your own DTOs and cast explicitly.Content\Handler::load() gives the current published version; drafts are invisible. Use the draft-specific and version-specific methods, and remember Content objects carry a versionInfo whose status is one of DRAFT/PUBLISHED/ARCHIVED (constants on the handler). "Where is my content?" is nearly always a version/status question, not a cache question.null means "all languages" and returns a fatter object; passing the wrong code returns a Content with an empty fields array and no error. Always verify with $content->versionInfo->names.sort_key_int/sort_key_string, location sorting and Field-based queries silently return wrong order — no exception, no warning. sort_key_string is a short VARCHAR, so long text gets truncated; guard for it.bin/console doctrine:query:sql against the ezcontentobject* tables to confirm what the handlers actually produced, and compare with $content->versionInfo->contentInfo. Reading the raw row is usually faster than instrumenting the handler chain.ibexa/core (or the metapackage) only. Pinning ibexa/core-persistence independently guarantees a composer update conflict on the next minor release.How can I help you explore Laravel packages today?