kaliop/ezobjectwrapperbundle
Kaliop eZObjectWrapperBundle provides a lightweight object wrapper for eZ Publish/Platform content in Symfony applications. It enables lazy loading and simpler access to content fields, attributes, and relations, helping developers integrate legacy eZ content into cleaner templates and controllers.
The bundle is a thin, read-oriented object-wrapper layer for eZ Publish 5 / eZ Platform (and, at v4, presumably Ibexa): it maps an eZ/Ibexa Content object to a PHP class of your own, so controllers and Twig templates can talk in domain terms ($article->getTitle(), article.children) instead of $content->getFieldValue('title')->text and raw ContentService calls.
Minimal steps:
composer require kaliop/ezobjectwrapperbundle:^4.0. The v4.0.0 release note is a single line ("useful f.e. for rendering previews of content"), so read CHANGELOG.md / UPGRADE*.md in the tag before installing — a 1.x → 4.0.0 jump almost certainly carries namespace, config-key and service-ID changes, and possibly an Ibexa namespace migration.config/bundles.php (or app/AppKernel.php on older Symfony), e.g. new Kaliop\eZObjectWrapperBundle\KaliopEzObjectWrapperBundle() — verify the FQCN in the tag, majors like this one often rename the bundle class/namespace.# config/packages/kaliop_ez_object_wrapper.yaml
kaliop_ez_object_wrapper:
content_types:
article:
wrapper: App\Wrapper\Article
Kaliop\eZObjectWrapperBundle\Core\Entity) and override only the getters you actually need.ContentService.Where to look first, in order:
CHANGELOG.md/UPGRADE.md — with a one-line release note, this is the only real source of truth about what v4 changed.Core/Entity.php — the base wrapper: what it gives you for free (getContent(), getContentInfo(), getLocation(), getFieldValue(), child/parent helpers, magic accessors), and what preview support was added around it.Core/Repository.php (or whatever the loader/service class is named in your installed version) — the getById / getByRemoteId / getByLocationId entry points, how the content-type→wrapper map is consulted, and any new version/draft-aware lookup.DependencyInjection/Configuration.php — the authoritative config schema; the README's YAML is often out of date relative to what the TreeBuilder accepts, and majors are exactly where keys get renamed.DependencyInjection/CompilerPass (if present) — how wrapper classes get registered/tagged; this is your extension point.First use case worth wiring up: pick one content type (article), write a wrapper with a handful of typed getters, resolve it by location ID in a controller, and render it in a Twig template. That exercises the mapping, the loader, languages and field value types all at once — the four things that bite later. Then re-render the same template against a draft version, which is the workflow v4.0.0 is pointing at.
// src/Wrapper/Article.php
namespace App\Wrapper;
use Kaliop\eZObjectWrapperBundle\Core\Entity;
class Article extends Entity
{
public function getTitle(): string
{
return $this->getFieldValue('title')->text;
}
public function getIntro(): string
{
$xml = $this->getFieldValue('intro')->xml;
return $xml ? strip_tags($xml->saveXML()) : '';
}
public function getPublishedAt(): ?\DateTime
{
return $this->getContent()->versionInfo->publishedDate;
}
}
// in a controller
$article = $this->get('ezobjectwrapper.repository')->getByLocationId($locationId);
If the service ID differs in your version, bin/console debug:container | grep -i wrapper will tell you immediately; that is usually faster than reading the extension class.
Typed getters over bare field access. Treat the wrapper as a view model: one method per field, returning scalars/DateTime/arrays rather than eZ FieldType value objects. This is the single biggest reason to use the package — templates stop needing ez_field_value() and field identifiers stop leaking into markup. Keep a couple of getFieldValue('x') calls in the wrapper as an escape hatch for one-off fields, but don't let templates call them.
Wrapper resolution at the controller boundary. Resolve wrappers in controllers or in a small service layer, then pass them to Twig as plain variables. Do not resolve them inside templates — you lose all error handling and you re-hit the repository on every render.
Preview rendering (the v4.0.0 use case). The release note's one concrete signal is previews, so treat "wrap the current user's draft, not the published version" as a first-class flow: in an editor/preview controller, load the content in the version being edited, wrap it with the same wrapper class the front end uses, and render the identical Twig template. That is the whole value — WYSIWYG fidelity without a second set of templates. Concretely: (a) resolve from a version/draft-aware loader path rather than getByLocationId, (b) check the repo's version-aware method names in Core/Repository.php before assuming they exist, (c) bypass HTTP cache/Twig cache for the preview route, and (d) gate the route behind ROLE_EDIT-style access checks, since draft content may be unpublished and/or in a non-public language.
Trees and navigation. The base class's children/parent helpers are where the wrapper pays for itself: a mapped folder wrapper returning Article[]/Folder[] children means menu rendering becomes a recursive Twig macro over plain objects. Guard the recursion with a depth limit (see Gotchas).
Relations. For relation/relation-list fields, get destination IDs from the field value and re-resolve through the wrapper repository so the related items come back wrapped:
/** @return Article[] */
public function getRelatedArticles(): array
{
$ids = $this->getFieldValue('related')->destinationContentIds;
return $ids ? $this->repository->findByIds($ids) : [];
}
Cache resolved relations on the instance (private $relatedCache) so a template that loops twice doesn't double-fetch.
Inheritance for shared fields. If several content types share title/intro/image, put those getters on an AbstractPage extends Entity and have Article, LandingPage, etc. extend it. Map each concrete content type to its concrete class. Interfaces are useful too, if you want to type-hint across types in services.
Interfaces + typed return on the loader. Because the loader returns whatever the map says, wrap the call in your own small helper (ContentResolver::articleByLocationId()) that asserts the class and gives your IDE and static analysis something to work with. This also isolates the service ID across upgrades — worth doing now, given the 1.x→4.x jump.
Language handling. Wrappers read through the eZ repository, so getFieldValue() respects the language set on the repository/language mask. Set languages before resolving ($repository->setLanguages([$lang])) or use sudo() with a language mask; otherwise you'll see empty strings for translated fields in the admin-preview or in a non-default-language siteaccess. Preview flows make this twice as likely to bite, because the draft's language may not match the current siteaccess.
Writes stay outside. The wrapper is read-only by design — and preview support does not change that: rendering a draft is still a read, saving one still goes through ContentService::updateContent with a typed ContentUpdateStruct. Mixing persistence into wrappers is the fastest way to make this package unmaintainable.
Testing. Unit-test wrappers by constructing them with a mocked Content (or a ContentService mock returning a stub Content) — the getters are pure mapping logic, so they're trivially testable. Integration-test the loader mapping with an eZ fixture content tree; that is where missing/incorrect content-type identifiers surface. Add one test that wraps a draft version and renders the preview template — that is the regression that v4.0.0's feature invites.
1.x and read composer.json (require constraints + license) before you commit. Pin to ^4.0 and commit the lockfile; do not let a floating constraint pull a 4.x patch into production untested.Core/ into your app if the upgrade path is unclear — at this size of dependency, owning ~10 files is cheaper than owning an upgrade.eZ\Publish\* / ez_content; Ibexa-era code uses Ibexa\* / ibexa_content. A v4 released after the rename may have moved wholesale, in which case your app-side wrappers, service IDs and Twig function names all need the same find-and-replace. grep -r "eZ\\\\Publish" vendor/kaliop/ settles it in seconds.content, location, parent or children, calling getContent() returns the eZ object, not your field. Always use getFieldValue('content') explicitly for those, or rename the identifier.getFieldValue('intro') returns TextBlock\Value; a relation-list returns RelationList\Value. If you skip the unwrapping step in your getters, every consumer has to know eZ field-type internals — which defeats the purpose.getChildren() per item in a loop re-issues location searches. For listings, resolve a batch of locations with a single search first, then wrap the results, or memoize the child collections on the wrapper instance.var_dump/json_encode will explode). Add an explicit $depth argument on tree helpers or a visited-set, and implement __toString()/jsonSerialize() with hard limits.== will fail. Compare getContentInfo()->id (or a getCacheKey() you add) when you need deduplication — and for previews, key on content id + version number, or a preview of v3 and the published v2 will collide in any cache you put in front of them.Cache-Control: private, no-store). Preview content is per-user, per-draft and permission-sensitive; the fastest way to leak unpublished copy is a shared fragment cache.sudo(). The repository uses the current user. Anonymous requests on restricted content either return empty fields or throw NotFoundException/UnauthorizedException. Decide per controller between catching and 404-ing, and use sudo() deliberately (and narrowly) for things like sitemaps or previews of content the current editor can read but the anonymous user cannot.cache:clear; stale container config silently keeps wrapping with the old class. debug:container plus debug:event-dispatcher are the two fastest ways to see what the compiler pass actually registered — and after a major upgrade they are the quickest way to discover a renamed service ID.API\Repository\Exceptions\NotFoundException; there is usually no find*-returns-null variant. Wrap it once in your own resolver so every controller doesn't repeat the try/catch.LocationService/SearchService and only wrap the resulting IDs — the package is a decoration layer, not a query layer.How can I help you explore Laravel packages today?