contenir / contenir-preview
The Contenir preview protocol: signed preview tokens from the CMS, their verification on a site, the request-scoped context a site renders a previewed record from, and shared preview links behind a code.
Requires
- php: ~8.3.0 || ~8.4.0 || ~8.5.0
- contenir/contenir-site-token: ^0.1
- peptolab/phpdb-migration: ^0.2
- php-db/phpdb: ^0.6.0
- psr/clock: ^1.0
- psr/container: ^1.1 || ^2.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- contenir/contenir-qa-tools: 0.1.x-dev
- infection/infection: ^0.35.6
- php-db/phpdb-mysql: 0.5.x-dev
- php-db/phpdb-sqlite: 0.3.x-dev
- phpunit/phpunit: ^11.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 00:45:21 UTC
README
The Contenir preview protocol: the CMS signs a preview token carrying a record and its current values, saved or not, and posts it into a frame on the site; the site verifies the token and renders the page that shows the record with those values in place of the stored ones.
This package is framework-free and holds the parts both sides share. The renderer for each site framework lives in its own package:
contenir/contenir-preview-laminas-mvccontenir/contenir-preview-mezzio(planned)
The protocol itself is in docs/protocol.md.
Shared configuration
Both sides build the same PreviewConfig: who issues tokens, for which site (both origins, with no
path), and the secrets that sign them by key id (at least 32 bytes each).
use Contenir\Preview\PreviewConfig; $config = new PreviewConfig( issuer: 'https://admin.example.com', audience: 'https://www.example.com', secrets: ['2026-10' => $secret], signWith: '2026-10', );
On the CMS: signing
use Contenir\Preview\PreviewRecord; use Contenir\Preview\Token\TokenSigner; $token = (new TokenSigner($config, $clock))->sign( new PreviewRecord('site.page.home', ['resource_id' => '101'], $formValues), (string) $userId, );
Post the token as the contenir_preview field into a frame aimed at $config->endpoint(): the
site's origin (the audience) with the protocol's fixed path, /api/resource/preview. Sign a new
token each time the preview refreshes: each is accepted once and lives five minutes.
On a site: verifying and rendering
A framework adapter does this for you. Underneath, it is:
use Contenir\Preview\Clock\SystemClock; use Contenir\Preview\PreviewContext; use Contenir\Preview\PreviewEntry; use Contenir\SiteToken\Replay\FileReplayGuard; use Contenir\Preview\Resolver\PathResolver; use Contenir\Preview\Resolver\ResolverRegistry; use Contenir\Preview\Resolver\RouteResolver; use Contenir\Preview\Token\TokenVerifier; $clock = new SystemClock(); $context = new PreviewContext(); $entry = new PreviewEntry( new TokenVerifier($config, $clock, new FileReplayGuard(sys_get_temp_dir(), $clock, 'contenir-preview-')), new ResolverRegistry([ 'site.page.home' => new PathResolver('/{slug}'), 'library.news.articles' => new RouteResolver('news/article', ['slug' => '{slug}']), ]), $context, ); $target = $entry->open($method, $postedToken); // throws InvalidPreview with its HTTP status // render $target as the site normally would: a path, a named route, or a handler it dispatches
A resolver can also answer with PreviewTarget::handler($controllerOrMiddleware, $params), which the
adapter dispatches without routing: the way to preview drafts, records not saved yet, and sites
whose routes are built from published records (contenir-workflow ships such a resolver).
Accepted tokens are remembered so each is used once: FileReplayGuard for one server (any existing
directory; it touches only its own files), CacheReplayGuard over a shared PSR-16 cache for several.
InMemoryReplayGuard lasts one process and suits only long-running servers and tests. The guards,
and the signing and checking of the token itself, come from
contenir/contenir-site-token, which other
protocols between the CMS and a site share; this package adds the record and the preview's own
wording for each refusal.
Wherever the site loads a previewable section's records, it lets the context lay the preview's values over them:
$row = $context->overlay('site.page.home', ['resource_id' => $row['resource_id']], $row);
and, for a record not saved yet, $context->unsaved('site.page.home') gives its values.
Every preview answer carries PreviewResponse::headers() (no caching or indexing, framed only by
the CMS), and a refusal answers with PreviewResponse::refusal() and the refusal's status.
Shared previews
An editor can send a client a link to one record's draft, behind a six-character code, which opens
until the end the editor chose (90 days at most) or until the CMS revokes it (§9 of the
protocol). Links live in the site database's preview_share
table, created by the migration in schema/ (ConfigProvider::schemaPath(), under
ConfigProvider::SCHEMA_NAMESPACE).
On the CMS, Share\Shares makes, lists, re-codes, revokes and prunes them:
use Contenir\Preview\Share\PhpDbShareStore; use Contenir\Preview\Share\ShareId; use Contenir\Preview\Share\Shares; $shares = new Shares(new PhpDbShareStore($siteDatabase), $clock); $share = $shares->create('library.projects.article', ['resource_id' => '7'], $ends, 'Jane, Acme', (string) $userId); ShareId::url($config->audience, $share->id); // https://www.example.com/preview/… $share->code; // K7M4QX
On a site, a framework adapter hands each visit to /preview/{id} (ShareId::fromPath()) to
Share\ShareEntry, and does what its ShareVisit says: render the target, show
PreviewResponse::codeForm() or wrongCodeForm(), or set the pass cookie and redirect back. Every
answer carries PreviewResponse::shareHeaders(). The record shows as it is saved, through the same
preview context, and PreviewRequest::$shared tells a client's visit from an editor's preview.
A site turns shared previews on by adding a share block to its contenir_preview configuration.
Container services
ConfigProvider registers the services a renderer needs in any PSR-11 container, built from the
application's contenir_preview configuration (Container\PreviewOptions documents its shape):
the options, the clock, the replay guard (a PSR-16 cache when one is named, otherwise files), the
resolver registry, the token verifier, the entry, the response and the preview context; and, for
shared previews, the store, the throttle and the share entry. Each
section's resolver is a path pattern (/{slug}), a named route with its parameters, or the name of
a PreviewResolverInterface service. Framework adapters merge getDependencies() into their own
configuration and add what their framework needs.
PreviewMarker::mark() places the visible "Preview" badge in a page's HTML.
Quality
composer check # Mago format, lint and analyze, then the tests composer mutation-test # Infection