{
    "type": "article",
    "title": "Publishing to Apple News from PHP",
    "canonical": "https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php",
    "path": "/writing/publishing-to-apple-news-from-php",
    "url": "https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php",
    "markdown": "https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php.md",
    "json": "https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php.json",
    "summary": "What it takes to publish an article to Apple News from PHP, from signing requests and building multipart uploads to the JSON details Apple’s format is strict about.",
    "published": "2026-03-21T00:00:00+00:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/publishing-to-apple-news-from-php/hero.png",
        "alt": "A diagram of PHP objects becoming a multipart POST of article.json, metadata and an image, signed with HHMAC, and sent to Apple News."
    },
    "fields": {
        "image_dark": null
    },
    "tags": [
        {
            "name": "PHP",
            "url": "https://www.dealmobile.co.uk/writing/tag/php",
            "json": "https://www.dealmobile.co.uk/writing/tag/php.json"
        },
        {
            "name": "Apple News",
            "url": "https://www.dealmobile.co.uk/writing/tag/apple-news",
            "json": "https://www.dealmobile.co.uk/writing/tag/apple-news.json"
        },
        {
            "name": "APIs",
            "url": "https://www.dealmobile.co.uk/writing/tag/apis",
            "json": "https://www.dealmobile.co.uk/writing/tag/apis.json"
        },
        {
            "name": "Publishing",
            "url": "https://www.dealmobile.co.uk/writing/tag/publishing",
            "json": "https://www.dealmobile.co.uk/writing/tag/publishing.json"
        },
        {
            "name": "Apple News API",
            "url": "https://www.dealmobile.co.uk/writing/tag/apple-news-api",
            "json": "https://www.dealmobile.co.uk/writing/tag/apple-news-api.json"
        }
    ],
    "projects": [
        {
            "name": "Apple News API",
            "url": "https://www.dealmobile.co.uk/projects/apple-news-api",
            "json": "https://www.dealmobile.co.uk/projects/apple-news-api.json"
        }
    ],
    "body": {
        "html": "<p>Publishing to Apple News comes in two parts. One is the Apple News Publisher API, an HTTP API with its own way of signing requests. The other is Apple News Format, or ANF, a JSON document that describes the article: its components, styles and layout. My <a href=\"/projects/apple-news-api\">Apple News API</a> library for PHP handles both. This is a walk through what each part involves, and a few details that are easy to get wrong.</p>\n<h2 id=\"signing-requests\">Signing requests</h2>\n<p>The API doesn’t use OAuth or a bearer token. Each request carries an <code>Authorization</code> header signed with HMAC-SHA256, using a key ID and a secret from your Apple News channel.</p>\n<p>The string to sign is the HTTP method, the full URL and the current date in UTC, joined with nothing in between. For a POST, the content type and the whole body are added on the end. The secret arrives base64-encoded, so it’s decoded before signing, and the signature is base64-encoded again:</p>\n<pre><code class=\"language-php\">$canonical = $method . $url . $date;\n\nif ($method === 'POST' &amp;&amp; $contentType !== null &amp;&amp; $body !== null) {\n    $canonical .= $contentType . $body;\n}\n\n$signature = base64_encode(hash_hmac('sha256', $canonical, base64_decode($secret, true), true));\n$header = sprintf('HHMAC; key=%s; signature=%s; date=%s', $keyId, $signature, $date);\n</code></pre>\n<p>Two things to watch. The body you sign must be byte for byte the body you send, multipart boundaries included. And a secret that isn’t valid base64 should fail loudly, so the library decodes in strict mode and throws if it can’t.</p>\n<h2 id=\"uploading-an-article\">Uploading an article</h2>\n<p>Creating an article is a <code>multipart/form-data</code> POST to the channel. The message must contain an <code>article.json</code> part. It can also have a <code>metadata</code> part, wrapped in a <code>data</code> key, and one part for each image that the article refers to with a <code>bundle://</code> URL.</p>\n<p>The client builds that body for you. You pass a map of bundle names to local files:</p>\n<pre><code class=\"language-php\">$article-&gt;addComponent(Photo::fromBundle('hero.jpg'));\n\n$client-&gt;createArticle($channelId, $article, null, [\n    'bundle://hero.jpg' =&gt; '/path/to/hero.jpg',\n]);\n</code></pre>\n<p>Updating needs one more thing: the article’s current revision token, from the last response. The client adds it to the metadata part, so the call is <code>updateArticle($articleId, $revision, $article)</code>. If the article has changed since, Apple answers with a 409 conflict, and you fetch a fresh revision and try again.</p>\n<h2 id=\"building-the-document\">Building the document</h2>\n<p>ANF documents can be large: dozens of component types, named text styles, component styles, layouts, animations and conditional rules for dark mode or narrow screens. The library models all of it as PHP objects with a fluent interface:</p>\n<pre><code class=\"language-php\">$article = Article::create(identifier: 'story-123', title: 'A headline', language: 'en');\n\n$article\n    -&gt;addComponent(new Title('A headline'))\n    -&gt;addComponent(new Heading('Background', level: 2))\n    -&gt;addComponent((new Body('The &lt;strong&gt;first&lt;/strong&gt; paragraph.'))-&gt;setFormat('html'));\n</code></pre>\n<p>Each object knows its own ANF <code>role</code> and serialises itself. The article collects them, along with its styles and metadata, into the final document.</p>\n<h2 id=\"the-empty-object-problem\">The empty object problem</h2>\n<p>This week’s release, 2.0.1, fixed a PHP problem that shows up with any strict JSON API. PHP has one array type for lists and maps, so <code>json_encode([])</code> produces <code>[]</code>. But in ANF an empty layout or style is an object, and Apple expects <code>{}</code>. Send <code>[]</code> and the document is invalid.</p>\n<p>The fix walks the finished data and swaps every empty array for an empty <code>stdClass</code>, which encodes as <code>{}</code>:</p>\n<pre><code class=\"language-php\">private static function convertEmptyArraysToObjects(mixed $data): mixed\n{\n    if (is_array($data)) {\n        if (empty($data)) {\n            return new \\stdClass();\n        }\n        return array_map(self::convertEmptyArraysToObjects(...), $data);\n    }\n    return $data;\n}\n</code></pre>\n<h2 id=\"when-apple-says-no\">When Apple says no</h2>\n<p>Apple’s error responses name the problem and the field: an error code such as <code>INVALID_DOCUMENT</code> and a key path such as <code>components[0].text</code>. The library turns them into an <code>AppleNewsException</code> with <code>getErrorCode()</code> and <code>getKeyPath()</code>, and a separate <code>AuthenticationException</code> for 401 and 403.</p>\n<h2 id=\"bring-your-own-http-client\">Bring your own HTTP client</h2>\n<p>The library doesn’t ship an HTTP client. It depends on the PSR-18 client and PSR-17 factory interfaces, so it works with Guzzle, Symfony’s client or whatever your application already uses, and has no framework dependency. It needs PHP 8.1 or later. PHPUnit tests and PHPStan at level 8 run on every push.</p>\n<pre><code class=\"language-bash\">composer require tomgould/apple-news-api guzzlehttp/guzzle guzzlehttp/psr7\n</code></pre>\n<p>The <a href=\"https://applenews.dealmobile.co.uk\">documentation</a> covers every class, and the code is on <a href=\"https://github.com/tomgould/Apple-News-API\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Publishing to Apple News from PHP'\ncanonical: 'https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php'\nurl: 'https://www.dealmobile.co.uk/writing/publishing-to-apple-news-from-php'\ntype: Post\npublished: '2026-03-21'\nsummary: 'What it takes to publish an article to Apple News from PHP, from signing requests and building multipart uploads to the JSON details Apple’s format is strict about.'\n---\n\n# Publishing to Apple News from PHP\n\nPublished 2026-03-21\n\n![A diagram of PHP objects becoming a multipart POST of article.json, metadata and an image, signed with HHMAC, and sent to Apple News.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/publishing-to-apple-news-from-php/hero.png.webp?itok=XQvhCe3Y)\n\nPublishing to Apple News comes in two parts. One is the Apple News Publisher API, an HTTP API with its own way of signing requests. The other is Apple News Format, or ANF, a JSON document that describes the article: its components, styles and layout. My [Apple News API](https://www.dealmobile.co.uk/projects/apple-news-api.md) library for PHP handles both. This is a walk through what each part involves, and a few details that are easy to get wrong.\n\n## Signing requests\n\nThe API doesn’t use OAuth or a bearer token. Each request carries an `Authorization` header signed with HMAC-SHA256, using a key ID and a secret from your Apple News channel.\n\nThe string to sign is the HTTP method, the full URL and the current date in UTC, joined with nothing in between. For a POST, the content type and the whole body are added on the end. The secret arrives base64-encoded, so it’s decoded before signing, and the signature is base64-encoded again:\n\n```php\n$canonical = $method . $url . $date;\n\nif ($method === 'POST' && $contentType !== null && $body !== null) {\n    $canonical .= $contentType . $body;\n}\n\n$signature = base64_encode(hash_hmac('sha256', $canonical, base64_decode($secret, true), true));\n$header = sprintf('HHMAC; key=%s; signature=%s; date=%s', $keyId, $signature, $date);\n\n```\n\nTwo things to watch. The body you sign must be byte for byte the body you send, multipart boundaries included. And a secret that isn’t valid base64 should fail loudly, so the library decodes in strict mode and throws if it can’t.\n\n## Uploading an article\n\nCreating an article is a `multipart/form-data` POST to the channel. The message must contain an `article.json` part. It can also have a `metadata` part, wrapped in a `data` key, and one part for each image that the article refers to with a `bundle://` URL.\n\nThe client builds that body for you. You pass a map of bundle names to local files:\n\n```php\n$article->addComponent(Photo::fromBundle('hero.jpg'));\n\n$client->createArticle($channelId, $article, null, [\n    'bundle://hero.jpg' => '/path/to/hero.jpg',\n]);\n\n```\n\nUpdating needs one more thing: the article’s current revision token, from the last response. The client adds it to the metadata part, so the call is `updateArticle($articleId, $revision, $article)`. If the article has changed since, Apple answers with a 409 conflict, and you fetch a fresh revision and try again.\n\n## Building the document\n\nANF documents can be large: dozens of component types, named text styles, component styles, layouts, animations and conditional rules for dark mode or narrow screens. The library models all of it as PHP objects with a fluent interface:\n\n```php\n$article = Article::create(identifier: 'story-123', title: 'A headline', language: 'en');\n\n$article\n    ->addComponent(new Title('A headline'))\n    ->addComponent(new Heading('Background', level: 2))\n    ->addComponent((new Body('The <strong>first</strong> paragraph.'))->setFormat('html'));\n\n```\n\nEach object knows its own ANF `role` and serialises itself. The article collects them, along with its styles and metadata, into the final document.\n\n## The empty object problem\n\nThis week’s release, 2.0.1, fixed a PHP problem that shows up with any strict JSON API. PHP has one array type for lists and maps, so `json_encode([])` produces `[]`. But in ANF an empty layout or style is an object, and Apple expects `{}`. Send `[]` and the document is invalid.\n\nThe fix walks the finished data and swaps every empty array for an empty `stdClass`, which encodes as `{}`:\n\n```php\nprivate static function convertEmptyArraysToObjects(mixed $data): mixed\n{\n    if (is_array($data)) {\n        if (empty($data)) {\n            return new \\stdClass();\n        }\n        return array_map(self::convertEmptyArraysToObjects(...), $data);\n    }\n    return $data;\n}\n\n```\n\n## When Apple says no\n\nApple’s error responses name the problem and the field: an error code such as `INVALID_DOCUMENT` and a key path such as `components[0].text`. The library turns them into an `AppleNewsException` with `getErrorCode()` and `getKeyPath()`, and a separate `AuthenticationException` for 401 and 403.\n\n## Bring your own HTTP client\n\nThe library doesn’t ship an HTTP client. It depends on the PSR-18 client and PSR-17 factory interfaces, so it works with Guzzle, Symfony’s client or whatever your application already uses, and has no framework dependency. It needs PHP 8.1 or later. PHPUnit tests and PHPStan at level 8 run on every push.\n\n```bash\ncomposer require tomgould/apple-news-api guzzlehttp/guzzle guzzlehttp/psr7\n\n```\n\nThe [documentation](https://applenews.dealmobile.co.uk) covers every class, and the code is on [GitHub](https://github.com/tomgould/Apple-News-API).\n\n- **Projects:** [Apple News API](https://www.dealmobile.co.uk/projects/apple-news-api.md)\n- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [Apple News](https://www.dealmobile.co.uk/writing/tag/apple-news.md), [APIs](https://www.dealmobile.co.uk/writing/tag/apis.md), [Publishing](https://www.dealmobile.co.uk/writing/tag/publishing.md), [Apple News API](https://www.dealmobile.co.uk/writing/tag/apple-news-api.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}