Skip to content

Reference

The PHP SDK

Everything in bifrost-orbit/cms-sdk for PHP websites, from setup and every client method to rendering components, preview, refresh on publish, SEO, errors, Laravel and Symfony.

Reference11 min read

The PHP SDK, bifrost-orbit/cms-sdk, connects a PHP website to CMS Orbit: a client for the delivery API, <head> tags, JSON-LD and sitemap XML, preview tokens, publish webhooks, a component renderer and a fake transport for tests. It works with Laravel, Symfony, WordPress or plain PHP, and is open source under the MIT licence.

It reads the same endpoints and answers with the same shapes as the JavaScript SDK, so anything in the Delivery API reference applies.

Install

Shell
composer require bifrost-orbit/cms-sdk

The package is bifrost-orbit/cms-sdk on Packagist, where you can see the latest version.

It needs PHP 8.1 or later. Requests go through ext-curl when it is installed and PHP streams when it is not, so there is nothing else to install. To use your own HTTP client, such as Guzzle or Symfony HttpClient, pass any PSR-18 client.

Set up

Create the keys in Configuration › API keys: a public key for published content and, for previews, a secret key from a staging environment.

.envShell
CMS_API_URL=https://api.bifrostorbit.com/cmsCMS_PUBLIC_KEY=cms_pk_...        # published contentCMS_PREVIEW_KEY=cms_sk_...       # drafts; staging onlyCMS_WEBHOOK_SECRET=...           # verifies publish webhooksCMS_DEFAULT_REGION=germany       # the region's key, from Configuration › Environments
PHP
use BifrostOrbit\Cms\Client;$cms = new Client(    apiKey: getenv('CMS_PUBLIC_KEY'),    baseUrl: getenv('CMS_API_URL'),    previewKey: getenv('CMS_PREVIEW_KEY') ?: null,    defaultRegion: getenv('CMS_DEFAULT_REGION') ?: null,);

Client::fromEnvironment() builds the same client from CMS_API_URL, CMS_PUBLIC_KEY, CMS_PREVIEW_KEY and CMS_DEFAULT_REGION, and Client::create([...]) takes the options as an array.

apiKey
What it does
A public key (cms_pk_…) for published content. Required. A secret key also works, since PHP runs on the server.
baseUrl
What it does
The API address. https://api.bifrostorbit.com/cms by default; https://api-stage.bifrostorbit.com/cms for staging.
previewKey
What it does
A secret key (cms_sk_…) for drafts. A public key is refused here, since it cannot read drafts.
defaultRegion
What it does
Used when a call names no region.
defaultLocale
What it does
Used when a call names no language. Without it, the region's own language answers.
timeout
What it does
Per attempt, in milliseconds. 5 seconds by default.
retries
What it does
Extra attempts after a network error or a server error. 2 by default.
httpClient
What it does
Any PSR-18 client, with requestFactory and streamFactory, or your own Transport.

An unknown option, on the client or on any call, is refused, so a typo never goes unnoticed.

Pages

PHP
use BifrostOrbit\Cms\Seo;$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);$result = $cms->resolve($path, ['referrer' => $_SERVER['HTTP_REFERER'] ?? null]);switch ($result['kind']) {    case 'redirect':        header('Location: ' . $result['to'], true, $result['status']);        exit;    case 'gone':        http_response_code(410);        exit;    case 'notFound':        http_response_code(404);        exit;}$page = $result['page'];echo Seo::headTags($page);   // title, description, canonical, robots, hreflang, Open Graph and X cardecho Seo::jsonLd($page);

Answers are plain PHP arrays in the shapes the Delivery API describes, and the PHPDoc on each method lists their keys for PHPStan, Psalm and your editor. Pages gain meta, which says which region and language answered and whether a fallback was used, as in the JavaScript SDK.

Pass region and locale to read another region or language than the client's defaults, and 'strict' => true to refuse every fallback, so the page must exist in exactly that language and region:

PHP
$page = $cms->getPage('/products/hiking-boots', ['region' => 'ch', 'locale' => 'fr-CH', 'strict' => true]);if ($page['meta']['fallbackApplied']) {    // another language answered}
PHP
$header = $cms->getNavigation('header');$footer = $cms->getGlobal('footer', ['region' => 'ch']);$articles = $cms->list('article', ['sort' => '-publishedAt', 'perPage' => 12]);$faqs = $cms->list('faqEntry', ['category' => 'pricing', 'include' => ['fields']]);$products = $cms->list('product', ['filter' => ['activity' => 'hiking']]);

A list answers with items, total, page and hasMore. Each item has id, url, name, contentType, publishedAt and seo, and fields when you pass 'include' => ['fields']. perPage is 20 by default and at most 100. To read every entry, ask for the next page until hasMore is false:

PHP
$page = 1;do {    $batch = $cms->list('article', ['page' => $page++, 'perPage' => 100]);    foreach ($batch['items'] as $item) {        // ...    }} while ($batch['hasMore']);

Pass 'include' => ['references'] to resolve, getPage, getGlobal or list to get the entries that Reference and References fields point at, rather than their ids.

Components

Each component in $page['components'] has id, type, schemaVersion, fields, children and variant. Blocks nested in a slot are on the slot's field too, so $block['fields']['button'] is the button's node or null, and a Components slot is a list. See Components and slots in Field types.

A switch on type is all it takes:

PHP
function renderBlock(array $block): string{    $f = $block['fields'];    $e = fn ($value) => htmlspecialchars((string) $value, ENT_QUOTES);    switch ($block['type']) {        case 'hero':            return '<section data-cms-id="' . $e($block['id']) . '">'                . '<h1>' . $e($f['heading'] ?? '') . '</h1>'                . ($f['description'] ?? '')                . (isset($f['button']) ? renderBlock($f['button']) : '')                . '</section>';        case 'button':            return '<a href="' . $e($f['link']['href'] ?? '#') . '">' . $e($f['label'] ?? '') . '</a>';        default:            return '';    }}echo implode('', array_map('renderBlock', $page['components']));

Or give BlockRenderer one renderer per component. Each gets the block and the renderer, so it can draw a slot with $blocks->slot($block, 'cards'):

PHP
use BifrostOrbit\Cms\BlockRenderer;$blocks = new BlockRenderer([    'hero' => fn (array $block) => view('blocks.hero', ['block' => $block]),    'cardGrid' => fn (array $block, BlockRenderer $blocks) => '<div class="grid">' . $blocks->slot($block, 'cards') . '</div>',]);echo $blocks->render($page['components']);

Rich text arrives as HTML, ready to print. Images arrive with url, width, height and alt; Images::url($url, width: 960, format: 'webp') and Images::srcSet($url, $width) give resized addresses. Put data-cms-id with the block's id on each component's outer element so editors can select it in the live preview.

Generate types

The type generator in the JavaScript package writes PHP classes for your space: one readonly class per component and content type, plus a Blocks map that turns delivered blocks into those classes. Run it with Node 20 or later whenever the schema changes:

Shell
npx --package @bifrost-orbit-cms/sdk cms-orbit generate --lang php --out src/Cms/Types.php --namespace "App\\Cms"

It reads CMS_API_URL and CMS_PUBLIC_KEY, or --url and --key. Add --include references if you load references with include.

Insights Orbit

When the space is connected to Insights Orbit under Settings, each delivered page carries id, the entry's id, and insights, ['site' => '…'] or null. Pages from an older CMS Orbit have neither key.

Insights::tags prints the Insights Orbit script with the site key and <meta name="insights-content"> with the entry's id, so the page's numbers follow it when its address changes. Without a site key it prints nothing, so it can stay in your layout:

PHP
use BifrostOrbit\Cms\Insights;echo Insights::tags($page);// or with your own key and a first-party address:echo Insights::tags($page, ['site' => getenv('INSIGHTS_SITE'), 'src' => '/stats/script.js', 'api' => '/stats/collect']);
site
Means
The site key. Taken from the page when left out
contentId
Means
The entry to tag page views with, instead of the page's id
src, api
Means
Where the script and the collector are; Insights Orbit's own addresses by default
disable
Means
Automatic events to switch off, such as ['scroll']
campaigns
Means
true by default: counts elements with data-banner="LABEL" or data-popup="LABEL". Put the campaign's trackingLabel there.

Insights::site($page) answers with the site key or null. See Insights Orbit in the JavaScript SDK for serving the script from your own domain.

Preview

The editor opens your website at the space's Preview URL with ?preview=<token>. Check the token, remember that this visitor is previewing, and read drafts while they are:

PHP
use BifrostOrbit\Cms\Preview;$token = $cms->verifyPreviewToken($_GET['preview'] ?? $_GET['token'] ?? '');if ($token === null) {    http_response_code(401);    exit('That preview link is invalid or has expired');}setcookie('cms_preview', '1', ['httponly' => true, 'secure' => true, 'samesite' => 'None', 'path' => '/']);header('Location: ' . Preview::redirectPath($_GET['path'] ?? null, $token));
PHP
$preview = isset($_COOKIE['cms_preview']);$result = $cms->resolve($path, ['preview' => $preview]);echo $preview ? Preview::bridgeScript($cms) : '';

'preview' => true sends the preview key with draft=true. Never cache draft answers. Preview::redirectPath only ever sends the editor to a page on your own website. See Set up the live preview for what the editor needs from your website.

Refresh on publish

PHP
use BifrostOrbit\Cms\Webhook;$message = Webhook::parse(getenv('CMS_WEBHOOK_SECRET'), getallheaders(), file_get_contents('php://input'));if ($message === null) {    http_response_code(401);    exit;}$tags = Webhook::tagsForEvent($message['event'], $message['data']);// clear the cache entries with these tagshttp_response_code(204);

Webhook::parse checks the signature and refuses messages over 5 minutes old. tagsForEvent answers with the same cache tags as the JavaScript SDK, such as cms:page:germany:/pricing, and CacheTags builds them when you store answers. See Refresh the website when content changes.

Sitemaps and robots

PHP
header('Content-Type: application/xml; charset=utf-8');echo Seo::sitemapXml($cms->getSitemap(['region' => 'germany']));// or, for the index of every region:echo Seo::sitemapIndexXml($cms->getSitemapIndex());

$cms->getRobots()['content'] is the robots.txt. Staging always answers with one that blocks crawlers. Seo::toMetadata($page) gives the same metadata array as the JavaScript toMetadata, if you would rather build the tags yourself.

Client methods

resolve
Answers with
A page, a redirect, gone or not found for an address. Call it before rendering.
getPage, findPage
Answers with
One page by address. find… answers null instead of throwing when nothing is published there.
list
Answers with
Entries of one content type, filtered and paged, with their fields when asked
getGlobal, findGlobal
Answers with
Site-wide content such as a footer, by its key
getNavigation, findNavigation
Answers with
A menu, by its key
getBanners, getPopups
Answers with
Banners and popups live now for a page. getBanners(null, ['everyPage' => true]) reads them for every page.
getRegions, getLocales
Answers with
The regions and languages the key may read
getSitemap, getSitemapIndex, getRobots
Answers with
Sitemaps and robots rules
getProducts, getTaxonomy
Answers with
Shop products with live prices, and taxonomy terms
graphql
Answers with
Any GraphQL query's data
getSchema
Answers with
Content types and components
verifyPreviewToken
Answers with
What a preview token is for, or null

Methods that read one thing take its address or key first. Every method then takes an array of options, with region, locale and preview where they apply. getSitemap and getProducts take only the array.

Errors

Failed calls throw a CmsException, or one of its kinds. Each has the HTTP status and the API's errorCode.

NotFoundException
When
404: nothing published at the address, or no menu, global entry, taxonomy, region or language with that key
AuthException
When
401: a missing, unknown or expired key, or a region or language the key may not read
RateLimitException
When
429: too many requests. retryAfter is the number of seconds to wait, from the Retry-After header.
UnavailableException
When
The CMS failed, could not be reached or did not answer in time, after the retries
CmsException
When
Anything else, such as 400 with the code bad_request

Network errors and server errors are retried twice, after 250 and 750 milliseconds, and each attempt times out after 5 seconds. A rate limit and other client errors are not retried. resolve answers notFound and gone rather than throwing, and verifyPreviewToken answers null for an invalid or expired token. A wrong option or key, or 'preview' => true without a previewKey, throws before any request is sent.

PHP
use BifrostOrbit\Cms\Exception\NotFoundException;use BifrostOrbit\Cms\Exception\RateLimitException;use BifrostOrbit\Cms\Exception\UnavailableException;try {    $footer = $cms->getGlobal('footer');} catch (NotFoundException) {    $footer = null;} catch (RateLimitException $error) {    header('Retry-After: ' . $error->retryAfter);    http_response_code(503);    exit;} catch (UnavailableException) {    $footer = $lastKnownFooter;}

Each key may make 15,000 requests a minute. See Rate limits in the Delivery API.

Caching

The client does not cache answers. The API's Cache-Control is meant for shared caches such as a CDN (s-maxage), so keep answers in your own cache, such as Laravel's Cache, Symfony Cache or APCu: about a minute for pages and lists, and five minutes for menus and site-wide content. Store them under the tags from CacheTags, such as CacheTags::page('germany', '/pricing') or CacheTags::navigation('header'), so a publish webhook can clear only what changed. Never cache answers read with 'preview' => true. See Caching and refreshing pages.

Laravel

PHP
// app/Providers/AppServiceProvider.phpuse BifrostOrbit\Cms\Client;public function register(): void{    $this->app->singleton(Client::class, fn () => new Client(        apiKey: config('services.cms.key'),        baseUrl: config('services.cms.url'),        previewKey: config('services.cms.preview_key'),        defaultRegion: config('services.cms.region'),    ));}
PHP
// app/Http/Controllers/PageController.phppublic function show(Client $cms, ?string $path = null){    $result = Cache::remember('cms:page:/' . $path, 60, fn () => $cms->resolve('/' . $path));    return match ($result['kind']) {        'redirect' => redirect($result['to'], $result['status']),        'gone' => abort(410),        'notFound' => abort(404),        'page' => view('page', ['page' => $result['page']]),    };}

Add 'cms' => ['url' => env('CMS_API_URL'), 'key' => env('CMS_PUBLIC_KEY'), 'preview_key' => env('CMS_PREVIEW_KEY'), 'region' => env('CMS_DEFAULT_REGION')] to config/services.php, and a catch-all route: Route::get('/{path?}', [PageController::class, 'show'])->where('path', '.*').

Symfony

YAML
# config/services.yamlservices:    BifrostOrbit\Cms\Client:        arguments:            $apiKey: '%env(CMS_PUBLIC_KEY)%'            $baseUrl: '%env(CMS_API_URL)%'            $previewKey: '%env(default::CMS_PREVIEW_KEY)%'            $defaultRegion: '%env(default::CMS_DEFAULT_REGION)%'
PHP
#[Route('/{path}', requirements: ['path' => '.*'], priority: -100)]public function show(Client $cms, string $path = ''): Response{    $result = $cms->resolve('/' . $path);    return match ($result['kind']) {        'redirect' => new RedirectResponse($result['to'], $result['status']),        'gone' => new Response('', 410),        'notFound' => throw new NotFoundHttpException(),        'page' => $this->render('page.html.twig', ['page' => $result['page']]),    };}

Tests

PHP
use BifrostOrbit\Cms\Testing\FakeTransport;$transport = (new FakeTransport())->json(['data' => ['key' => 'main', 'name' => 'Main menu', 'items' => []]]);$cms = new Client(apiKey: 'cms_pk_test', httpClient: $transport);

FakeTransport answers from what you queue with json() or fail(), and records each request in requests, so tests never call the CMS.

Was this page helpful?

Read next