# 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.

Source: https://docs.bifrostorbit.com/cms/developers/sdk-php/

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](https://docs.bifrostorbit.com/cms/developers/sdk/), so anything in the [Delivery API](https://docs.bifrostorbit.com/cms/developers/delivery-api/) reference applies.

## Install

```bash
composer require bifrost-orbit/cms-sdk
```

The package is [`bifrost-orbit/cms-sdk` on Packagist](https://packagist.org/packages/bifrost-orbit/cms-sdk), 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.

```bash
# .env
CMS_API_URL=https://api.bifrostorbit.com/cms
CMS_PUBLIC_KEY=cms_pk_...        # published content
CMS_PREVIEW_KEY=cms_sk_...       # drafts; staging only
CMS_WEBHOOK_SECRET=...           # verifies publish webhooks
CMS_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.

| 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

```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 card
echo Seo::jsonLd($page);
```

Answers are plain PHP arrays in the shapes the [Delivery API](https://docs.bifrostorbit.com/cms/developers/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
}
```

## Menus, global content and lists

```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](https://docs.bifrostorbit.com/cms/developers/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:

```bash
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']);
```

| 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](https://docs.bifrostorbit.com/cms/developers/sdk/) 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](https://docs.bifrostorbit.com/cms/developers/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 tags
http_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](https://docs.bifrostorbit.com/cms/developers/webhooks/).

## 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

| 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](https://docs.bifrostorbit.com/cms/developers/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`.

| 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.

```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](https://docs.bifrostorbit.com/cms/developers/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](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/).

## Laravel

```php
// app/Providers/AppServiceProvider.php
use 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.php
public 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.yaml
services:
    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.
