tobento / service-renderable
Renderable service for PHP applications.
Requires
- php: >=8.4
- psr/container: ^2.0
- tobento/service-autowire: ^2.0
- tobento/service-collection: ^2.0
- tobento/service-helper-function: ^2.0
- tobento/service-iterable: ^2.0
- tobento/service-support: ^2.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^12.3
- tobento/service-container: ^2.0
- tobento/service-dater: ^2.0
- tobento/service-picture-generator: ^2.0
- tobento/service-repository-storage: ^2.0
- tobento/service-view: ^2.0
- vimeo/psalm: ^6.13
Suggests
- tobento/service-dater: Required for using the Date renderable (requires the PHP intl extension)
- tobento/service-picture-generator: Required for using the Picture renderable
- tobento/service-repository-storage: Required for using the repository entity and entity factory
Provides
None
Conflicts
None
Replaces
None
README
The Renderable Service turns raw values, such as repository entity attributes, into renderable objects which are safe and convenient to use in views.
Each value is rendered according to its type: text gets escaped, HTML gets sanitized, dates get formatted, files get download links, and so on.
Renderables are simple value objects holding no container or view, while the factory prepares the values (sanitizing, urls, locales).
Integrations such as an HTML sanitizer or file url generator are passed as callables, keeping the service free of dependencies on specific implementations.
Key Capabilities
- Safe by default: Text is escaped, HTML is sanitized or escaped, unsafe link urls are never rendered as links.
- Typed renderables: Text, Html, Data, List, Date, File, Files, Picture and Link.
- Lazy and cached: Renderables are created on first access only and cached.
- Translations: Translated values are resolved by locale, with fallbacks.
- Repository integration: Renderable entities for Storage Repositories.
- Extensibility: Add custom renderable types or override built-in ones.
Table of Contents
Getting started
Add the latest version of the renderable project running this command.
composer require tobento/service-renderable
Requirements
- PHP 8.4 or above
Optional, depending on the renderables used:
- tobento/service-dater and the PHP intl extension for the Date renderable
- tobento/service-picture-generator for the Picture renderable
- tobento/service-repository-storage for the Repository Entity
Documentation
Basic Usage
Register the renderable factory in your container and set the container on the helper function, which renderables use to fetch the factory lazily.
Any PSR-11 container can be used. The examples use the Container Service:
composer require tobento/service-container
use Psr\Container\ContainerInterface; use Tobento\Service\Container\Container; use Tobento\Service\HelperFunction\Functions; use Tobento\Service\Renderable\RenderableFactory; use Tobento\Service\Renderable\RenderableFactoryInterface; $container = new Container(); $container->set(RenderableFactoryInterface::class, RenderableFactory::class); new Functions()->set(ContainerInterface::class, $container);
Create renderables from attributes and a mapping of attribute names to renderable types:
use Tobento\Service\Renderable\Renderables; $renderables = new Renderables( mapping: [ 'title' => 'text', 'content' => 'html', 'published' => 'date', ], attributes: [ 'title' => 'Lorem <b>ipsum</b>', 'content' => '<p>Lorem ipsum</p>', 'published' => '2026-10-15', ], ); echo $renderables->get('title'); // Lorem <b>ipsum</b>
In views, for instance using the View Service:
<h1><?= $view->esc($entity->get('title')) ?></h1> <?php if ($entity->exists('content')) { ?> <div class="content"><?= $view->esc($entity->get('content')) ?></div> <?php } ?>
Escaping
Renderables implement the Htmlable interface of the Support Service. Escaping a renderable using $view->esc() of the View Service or Str::esc() returns its rendered output as-is, as renderables already return safe HTML.
So in views, always use $view->esc(), whether a value is a renderable or not.
Raw values returned by methods such as $data->get('key') or $file->label() are not escaped and must be escaped in views.
Renderables
use Tobento\Service\Renderable\Renderables; use Tobento\Service\Renderable\RenderablesInterface; use Tobento\Service\Renderable\RenderableFactoryInterface; $renderables = new Renderables( // Attribute name to renderable type: mapping: ['title' => 'text'], // The raw attributes: attributes: ['title' => 'Lorem'], // The container id of the renderable factory to use: factory: RenderableFactoryInterface::class, // default ); var_dump($renderables instanceof RenderablesInterface); // bool(true)
Methods
// Returns the renderable (created lazily and cached): $renderable = $renderables->get('title'); // Returns true if the attribute exists: $renderables->has('title'); // Returns true if the attribute value is empty or not: $renderables->empty('title'); $renderables->exists('title'); // Returns the raw attribute value: $renderables->raw('title', default: null); // Returns all raw attributes: $renderables->attributes(); // Returns all attributes as array, converting Arrayable values // (e.g. repository translations) to arrays: $renderables->toArray();
The renderable factory is not stored on the instance, keeping dumps lightweight. Only its container id is stored, and the factory is fetched lazily from the current helper function container. So when running multiple apps in one process (e.g. using Apps), each app uses the factory of its own container.
Typed Renderables
The get method returns a NullRenderable for missing or unknown types, so calling a class-specific method on the result may fail. Use the getAs method to ensure the renderable is an instance of the given class. If the mapped renderable is not, it is created by the given type instead:
$price = $renderables->getAs(name: 'price', class: Money::class, type: 'money'); $price->currency(); // safe to call Money methods
A RenderableException is thrown if the renderable cannot be created as the given class, for instance if a custom factory for the type returns another renderable.
For the built-in renderables, the following shortcut methods are available:
$renderables->date('published')->date('dd.MM.yyyy'); $renderables->files('downloads')->files();
Available methods: data, date, file, files, html, link, list, picture and text.
The getAs and the shortcut methods are provided by the HasTypedRenderables trait, which you may use in your own RenderablesInterface implementations.
Renderable Factory
The factory creates renderables by type. Unknown or missing types return a Null Renderable.
The RenderableFactory implements the CompositeRenderableFactoryInterface, a factory composed of other factories each creating the renderables for a specific type (see Custom Renderable Types).
use Tobento\Service\Renderable\RenderableFactory; $factory = new RenderableFactory( container: $container, // Custom renderable factories by type: factories: [], // Whether to generate images in background (queue): generateImagesInBackground: true, // default // Sanitizes untrusted HTML, if null HTML gets escaped: htmlSanitizer: null, // Generates file urls, if null files are rendered without link: fileUrlGenerator: null, // The locale used for translated values: locale: 'en', // default localeFallbacks: ['de-CH' => 'de'], );
Built-in types
| Type | Renderable |
|---|---|
data |
Data |
date |
Date, with time if the value contains a time |
datetime |
Date, always with time |
file |
File |
files |
Files |
html |
Html |
link |
Link |
list |
List |
picture |
Picture |
text |
Text |
Html Sanitizer
Pass a callable sanitizing untrusted HTML, for instance using the App Html Sanitizer:
use Tobento\App\HtmlSanitizer\HtmlSanitizerInterface; $factory = new RenderableFactory( container: $container, htmlSanitizer: fn (string $html): string => $container->get(HtmlSanitizerInterface::class)->sanitize($html), );
Without a sanitizer, HTML gets escaped.
File Url Generator
Pass a callable generating the file url from the storage name and file path, for instance using the App Media file download feature:
use Tobento\Service\Routing\RouterInterface; $factory = new RenderableFactory( container: $container, fileUrlGenerator: fn (string $storage, string $path): string => (string)$container->get(RouterInterface::class)->url( name: 'media.file.download', parameters: ['storage' => $storage, 'path' => $path], throw: false, ), );
Without a generator, files are rendered without link.
The callable also receives the file Item as third argument, giving access to any file attribute. For instance, to create signed and expiring urls for files in private storages, only if a signed_url_expires attribute (in minutes) is set.
The example uses the File Storage Service and the Routing Service, and assumes download routes named media.file.download and media.file.download.signed exist:
use Tobento\Service\FileStorage\StoragesInterface; use Tobento\Service\Renderable\Collection\Item; use Tobento\Service\Routing\RouterInterface; $factory = new RenderableFactory( container: $container, fileUrlGenerator: function (string $storage, string $path, Item $file) use ($container): string { $storages = $container->get(StoragesInterface::class); $router = $container->get(RouterInterface::class); if (! $storages->has($storage)) { return ''; } if ($storages->get($storage)->isPrivate()) { $minutes = (int)$file->raw('signed_url_expires', 0); if ($minutes <= 0) { return ''; } return (string)$router->url('media.file.download.signed', ['storage' => $storage, 'path' => $path]) ->sign(new \DateTime('now +'.$minutes.' minutes')); } return (string)$router->url('media.file.download', ['storage' => $storage, 'path' => $path]); }, );
Locale
The locale and locale fallbacks are used to resolve translated values of File, Files, Picture and Link renderables. As the factory is usually a shared instance, create it once the current locale is known.
Date renderables are rendered using the factory locale as well. The timezone and default date formats are taken from the DateFormatter in the container if available, so configure them there. Otherwise, a new DateFormatter with its defaults is used.
Custom Renderable Types
Add a factory for a custom type, or to override a built-in type, using the addFactory method of the CompositeRenderableFactoryInterface. Factories are resolved lazily on first use by autowiring:
use Tobento\Service\Renderable\RenderableFactoryInterface; use Tobento\Service\Renderable\RenderableInterface; use Tobento\Service\Renderable\RenderablesInterface; class BadgeFactory implements RenderableFactoryInterface { public function createRenderable( string $name, null|string $type, mixed $value, RenderablesInterface $renderables, ): RenderableInterface { return new Badge(is_string($value) ? $value : ''); } } $factory->addFactory(type: 'badge', factory: BadgeFactory::class); // or with constructor parameters: $factory->addFactory(type: 'badge', factory: [BadgeFactory::class, 'color' => 'red']); // or an instance: $factory->addFactory(type: 'badge', factory: new BadgeFactory());
Type hint the CompositeRenderableFactoryInterface if you need to add factories:
use Tobento\Service\Renderable\CompositeRenderableFactoryInterface; use Tobento\Service\Renderable\RenderableFactoryInterface; $factory = $container->get(RenderableFactoryInterface::class); if ($factory instanceof CompositeRenderableFactoryInterface) { $factory->addFactory(type: 'badge', factory: BadgeFactory::class); }
Renderables should only receive the services they need, never the container itself.
You may also extend the factory and override the protected create* methods of built-in types.
Available Renderables
All renderables implement the RenderableInterface:
$renderable->render(); // string, the safe HTML output $renderable->value(); // mixed, the raw underlying value $renderable->toHtml(); // same as render() (string)$renderable; // same as render()
Data
Holds structured data such as options or key-value data. It is not rendered directly.
use Tobento\Service\Renderable\Data; $data = new Data(['layout' => ['image' => 'left']]); $data->has('layout.image'); // true $data->get('layout.image', default: null); // 'left' $data->contains('left'); // false, checks top level values only
Date
Renders a date or date time using the Date Formatter. Empty or invalid values render an empty string, they never fall back to the current date.
use Tobento\Service\Dater\DateFormatter; use Tobento\Service\Renderable\Date; $date = new Date( value: '2026-10-15 10:30', // string, timestamp or DateTimeInterface formatter: new DateFormatter(), // true with time, false date only, null to detect it from the value: withTime: null, // default // The Intl patterns used when rendering, null for the formatter defaults: dateFormat: null, // e.g. 'dd.MM.yyyy' dateTimeFormat: null, // e.g. 'dd.MM.yyyy HH:mm' // The locale used when rendering, null for the formatter locale: locale: 'de-CH', ); echo $date; // Donnerstag, 15. Oktober 2026, 10:30 $date->date(format: 'dd.MM.yyyy'); // 15.10.2026 $date->dateTime(); // Donnerstag, 15. Oktober 2026, 10:30 $date->dateTime(locale: 'en'); // Thursday, 15. October 2026, 10:30 $date->format('Y-m-d'); // 2026-10-15 $date->toDateTime(); // null|DateTimeInterface $date->inPast(); // bool $date->has(); // bool, if a valid date exists $date->withTime(); // bool, if rendered with time
The date and dateTime methods use the given locale, otherwise the date locale, otherwise the formatter locale.
Time detection
If withTime is null, the time is detected from the value: strings with a time part (e.g. 2026-10-15 10:30 or 2026-10-15T10:30), timestamps and dates with a time other than midnight are rendered with time. So a value from a date column (2026-10-15) renders the date only, and a value from a datetime column renders the date with time.
Keep in mind that datetime columns always return a time (e.g. 2026-10-15 00:00:00), so store dates without time in date columns.
Factory value
A date (string, timestamp or DateTimeInterface), or an array to set the formats per value, for instance per field:
[
'date' => '2026-10-15',
'date_format' => 'dd.MM.yyyy', // optional
'date_time_format' => 'dd.MM.yyyy HH:mm', // optional
]
The factory creates dates with its locale, using the DateFormatter from the container if available.
In views:
<time datetime="<?= $view->esc($entity->date('published')->format('Y-m-d')) ?>"> <?= $view->esc($entity->date('published')) ?> </time>
File
Renders a download link to the file, or its label only if no url is available.
use Tobento\Service\Renderable\Collection\Item; use Tobento\Service\Renderable\File; $file = new File( file: new Item( attributes: [ 'src' => 'path/to/file.pdf', 'title' => ['en' => 'Manual', 'de' => 'Anleitung'], 'url' => 'https://example.com/downloads/file.pdf', ], locale: 'de', ), // The picture definition(s) for custom views, e.g. image previews: definition: 'renderable-file', // default // or named definitions: ['default' => 'renderable-file', 'thumb' => 'file-thumb'] ); echo $file; // <a class="renderable-file" href="https://example.com/downloads/file.pdf">Anleitung</a> $file->url(); // string, empty if not available $file->label(); // string, the first non-empty "title" or "name", otherwise the filename $file->file(); // Item, to access any attribute: $file->file()->localized('alt') $file->definition(); // the default picture definition $file->definition('thumb'); // a named picture definition $file->withDefinition('preview'); // returns a new instance
Files without src render an empty string, and files without url render the label only:
<span class="renderable-file">Anleitung</span>
Factory value
[
'src' => 'path/to/file.pdf', // may be translated
'storage' => 'downloads', // default
'title' => 'Manual', // optional, may be translated
'name' => 'manual.pdf', // optional, may be translated
]
The factory adds the url attribute using the File Url Generator, unless the value contains one already.
Files
Renders a list of download links. Files without src are skipped, and files without url render the label only.
use Tobento\Service\Renderable\Collection\Items; use Tobento\Service\Renderable\Files; $files = new Files( files: new Items( items: [ ['src' => 'manual.pdf', 'title' => 'Manual', 'url' => 'https://example.com/downloads/manual.pdf'], ['src' => 'terms.pdf', 'title' => 'Terms'], ], locale: 'en', ), // The picture definition(s) for custom views, e.g. image previews: definition: 'renderable-files', // default ); echo $files; // <ul class="renderable-files"> // <li class="renderable-files-item"><a href="https://example.com/downloads/manual.pdf">Manual</a></li> // <li class="renderable-files-item">Terms</li> // </ul> $files->files(); // Items $files->label($item); // string, same rule as for File $files->definition(); // the default picture definition $files->definition('thumb'); // a named picture definition $files->withDefinition('preview'); // returns a new instance
Factory value
An array of File values. The factory adds the url attribute to each file using the File Url Generator, unless it contains one already.
In views:
<?php $files = $entity->files('downloads'); ?> <?php foreach ($files->files() as $file) { ?> <a href="<?= $view->esc($file->get('url', '')) ?>"> <?= $view->esc($files->label($file)) ?> </a> <?php } ?>
Html
Renders the sanitized HTML, or the escaped HTML if no sanitized HTML is given. Htmlable values are trusted and rendered as-is.
use Tobento\Service\Renderable\Html; use Tobento\Service\Support\HtmlString; echo new Html(html: '<p>Lorem</p>', sanitized: '<p>Lorem</p>'); // <p>Lorem</p> echo new Html(html: '<p>Lorem</p>'); // <p>Lorem</p> echo new Html(html: new HtmlString('<p>Lorem</p>')); // <p>Lorem</p>
Link
Renders a link if the url is safe, otherwise the label as plain text. Only relative urls and urls with an allowed scheme are safe, so values such as javascript:alert(1) are never rendered as links.
use Tobento\Service\Renderable\Link; $link = new Link( url: 'https://example.com', label: 'Example', // if empty, the url is used newWindow: true, // adds target="_blank" rel="noopener noreferrer" schemes: ['http', 'https', 'mailto', 'tel'], // default ); $link->url(); // string, the url if safe, otherwise empty $link->label(); // string $link->newWindow(); // bool $link->isSafe(); // bool $link->isExternal(); // bool, if http or https
Factory value
A url string, or:
[
'url' => 'https://example.com', // may be translated
'label' => 'Example', // optional, may be translated
'new_window' => true, // optional
]
List
Renders the items as an HTML-escaped list.
use Tobento\Service\Renderable\ListRenderable; echo new ListRenderable(['Foo', 'Bar']); // <ul class="renderable-list"><li>Foo</li><li>Bar</li></ul>
Null Renderable
Returned by the factory for missing or unknown types. It renders an empty string.
use Tobento\Service\Renderable\NullRenderable; $null = new NullRenderable(); echo $null; // '' $null->render(); // '' $null->value(); // ''
Picture
Renders a responsive <picture> element using the Picture Generator, and a <figure> if a caption is given.
use Tobento\Service\Picture\Generator\PictureGeneratorInterface; use Tobento\Service\Renderable\Picture; $picture = new Picture( pictureGenerator: $container->get(PictureGeneratorInterface::class), path: 'path/to/image.jpg', resource: 'images', // string or ResourceInterface definition: 'renderable-image', // default, string or DefinitionInterface imgAlt: 'Alternative text', imgWidth: 300, // only applied if 50 or more figcaption: 'Caption', // if empty, no figure is rendered generateImagesInBackground: true, // default, generate images using the queue imgTitle: 'Title', // the img title attribute ); echo $picture; // <figure><picture>...<img src="..." alt="Alternative text" title="Title" width="300"></picture><figcaption>Caption</figcaption></figure>
All with methods return a new instance:
echo $picture ->withDefinition('hero') ->withFigureAttributes(['class' => 'figure']) ->withFigcaptionAttributes(['class' => 'caption']);
Paths without an image render an empty string.
Factory value
[
'path' => 'path/to/image.jpg', // may be translated
'resource' => 'images', // string or ResourceInterface
'definition' => 'renderable-image', // default
'alt' => 'Alternative text', // may be translated
'title' => 'Title', // optional, the img title attribute, may be translated
'width' => 300, // only applied if 50 or more
'caption' => 'Caption', // may be translated
]
The src and storage keys are accepted as fallbacks for path and resource, as stored by file uploads (e.g. App Crud file fields).
The renderable-image picture definition must be defined in your picture configuration, unless you pass another definition. The factory sets generateImagesInBackground from its own option.
Text
Renders the HTML-escaped text.
use Tobento\Service\Renderable\Text; echo new Text('Lorem <b>ipsum</b>'); // Lorem <b>ipsum</b>
Collection Item And Items
File renderables use the Item and Items classes to access attributes with translations:
use Tobento\Service\Renderable\Collection\Item; $item = new Item( attributes: ['title' => ['en' => 'Manual', 'de' => 'Anleitung']], locale: 'de', localeFallbacks: ['de-CH' => 'de'], ); // Value of the locale or fallback locale: $item->get('title', default: ''); // 'Anleitung' // Value of the locale, the fallback locale or the first available translation: $item->localized('title', default: ''); // 'Anleitung' // Raw value: $item->raw('title'); // ['en' => 'Manual', 'de' => 'Anleitung']
Use localized for output, as it returns a string and falls back to any available translation.
Repository Entity
Use the entity factory with a Storage Repository to get renderable entities. Translated attributes are resolved to the current locale, or its locale fallback:
use Tobento\Service\Renderable\RenderableFactoryInterface; use Tobento\Service\Renderable\Repository\EntityFactory; use Tobento\Service\Renderable\Repository\EntityInterface; $repository = new ProductRepository( storage: $storage, table: 'products', entityFactory: new EntityFactory( // Attribute name to renderable type: mapping: [ 'title' => 'text', 'description' => 'html', 'image' => 'picture', ], // The container id of the renderable factory to use: renderableFactory: RenderableFactoryInterface::class, // default ), ); $entity = $repository->findById(1); var_dump($entity instanceof EntityInterface); // bool(true)
The Entity class extends Renderables, so all Renderables methods are available. The toArray method returns translated attributes as arrays keyed by locale (e.g. ['title' => ['en' => 'Title', 'de' => 'Titel']]).
In views, hint the Entity class to get autocompletion for the Typed Renderables methods:
<?php /** @var \Tobento\Service\Renderable\Repository\Entity $entity */ ?>
Domain Objects
Domain objects such as products or orders may have their own entity factory and methods (e.g. $product->prices()). One option to render them is keeping the domain object free of rendering and implementing the RenderablesAwareInterface, returning renderables of its attributes:
use Tobento\Service\Renderable\Renderables; use Tobento\Service\Renderable\RenderablesAwareInterface; use Tobento\Service\Renderable\RenderablesInterface; interface ProductInterface extends RenderablesAwareInterface { public function sku(): string; } class Product implements ProductInterface { public function __construct( protected array $attributes, ) {} public function sku(): string { return is_string($this->attributes['sku'] ?? null) ? $this->attributes['sku'] : ''; } public function toRenderables(): RenderablesInterface { return new Renderables( mapping: ['title' => 'text', 'image' => 'picture'], attributes: $this->attributes, ); } }
Values needing domain logic (e.g. prices) are prepared by the domain object or its entity factory, the renderables only output them.
In views:
<?php $item = $product->toRenderables(); ?> <h1><?= $view->esc($item->get('title')) ?></h1> <?= $view->esc($item->get('image')) ?>
Any object implementing the RenderablesAwareInterface can be rendered generically:
if ($object instanceof RenderablesAwareInterface) { $renderables = $object->toRenderables(); }