Search by

tobento / service-renderable

TOBENTOch

Renderable service for PHP applications.

Package info

github.com/tobento-ch/service-renderable

Homepage

pkg:composer/tobento/service-renderable

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0 2026-10-18 07:38 UTC

This package is auto-updated.

Last update: 2026-10-18 07:41:08 UTC


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:

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 &lt;b&gt;ipsum&lt;/b&gt;

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>');
// &lt;p&gt;Lorem&lt;/p&gt;

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 &lt;b&gt;ipsum&lt;/b&gt;

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();
}

Credits