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
composer require bifrost-orbit/cms-sdkThe 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.
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 › Environmentsuse 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.
apiKeycms_pk_…) for published content. Required. A secret key also works, since PHP runs on the server.baseUrlhttps://api.bifrostorbit.com/cms by default; https://api-stage.bifrostorbit.com/cms for staging.previewKeycms_sk_…) for drafts. A public key is refused here, since it cannot read drafts.defaultRegiondefaultLocaletimeoutretrieshttpClientrequestFactory and streamFactory, or your own Transport.| Option | What it does |
|---|---|
apiKey | A public key (cms_pk_…) for published content. Required. A secret key also works, since PHP runs on the server. |
baseUrl | The API address. https://api.bifrostorbit.com/cms by default; https://api-stage.bifrostorbit.com/cms for staging. |
previewKey | A secret key (cms_sk_…) for drafts. A public key is refused here, since it cannot read drafts. |
defaultRegion | Used when a call names no region. |
defaultLocale | Used when a call names no language. Without it, the region's own language answers. |
timeout | Per attempt, in milliseconds. 5 seconds by default. |
retries | Extra attempts after a network error or a server error. 2 by default. |
httpClient | 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
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:
$page = $cms->getPage('/products/hiking-boots', ['region' => 'ch', 'locale' => 'fr-CH', 'strict' => true]);if ($page['meta']['fallbackApplied']) { // another language answered}Menus, global content and lists
$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:
$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:
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'):
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:
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:
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']);sitecontentIdidsrc, apidisable['scroll']campaignstrue by default: counts elements with data-banner="LABEL" or data-popup="LABEL". Put the campaign's trackingLabel there.| Option | Means |
|---|---|
site | The site key. Taken from the page when left out |
contentId | The entry to tag page views with, instead of the page's id |
src, api | Where the script and the collector are; Insights Orbit's own addresses by default |
disable | Automatic events to switch off, such as ['scroll'] |
campaigns | 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:
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));$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
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
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
resolvegetPage, findPagefind… answers null instead of throwing when nothing is published there.listgetGlobal, findGlobalgetNavigation, findNavigationgetBanners, getPopupsgetBanners(null, ['everyPage' => true]) reads them for every page.getRegions, getLocalesgetSitemap, getSitemapIndex, getRobotsgetProducts, getTaxonomygraphqldatagetSchemaverifyPreviewTokennull| Method | Answers with |
|---|---|
resolve | A page, a redirect, gone or not found for an address. Call it before rendering. |
getPage, findPage | One page by address. find… answers null instead of throwing when nothing is published there. |
list | Entries of one content type, filtered and paged, with their fields when asked |
getGlobal, findGlobal | Site-wide content such as a footer, by its key |
getNavigation, findNavigation | A menu, by its key |
getBanners, getPopups | Banners and popups live now for a page. getBanners(null, ['everyPage' => true]) reads them for every page. |
getRegions, getLocales | The regions and languages the key may read |
getSitemap, getSitemapIndex, getRobots | Sitemaps and robots rules |
getProducts, getTaxonomy | Shop products with live prices, and taxonomy terms |
graphql | Any GraphQL query's data |
getSchema | Content types and components |
verifyPreviewToken | 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.
NotFoundException404: nothing published at the address, or no menu, global entry, taxonomy, region or language with that keyAuthException401: a missing, unknown or expired key, or a region or language the key may not readRateLimitException429: too many requests. retryAfter is the number of seconds to wait, from the Retry-After header.UnavailableExceptionCmsException400 with the code bad_request| Exception | When |
|---|---|
NotFoundException | 404: nothing published at the address, or no menu, global entry, taxonomy, region or language with that key |
AuthException | 401: a missing, unknown or expired key, or a region or language the key may not read |
RateLimitException | 429: too many requests. retryAfter is the number of seconds to wait, from the Retry-After header. |
UnavailableException | The CMS failed, could not be reached or did not answer in time, after the retries |
CmsException | 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.
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
// 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'), ));}// 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
# 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)%'#[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
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?