Tom Gould

Writing

Live coverage on Apple News from PHP

How the Apple News API library builds Live Coverage articles, the rules it checks before Apple does, and the request metadata that starts and ends the coverage.

Some stories don’t arrive in one piece. An election night, a match or a storm is covered over hours, one short update at a time, and Apple News has a format for that called Live Coverage. Version 2.1.0 of my Apple News API library for PHP, dated 15 October 2026 in its changelog, added support for it. Publishing to Apple News from PHP covers signing requests and uploading articles. This post is about live articles: what Apple expects, what the library checks before anything is sent, and how an article goes from live to finished.

One thing first. Live Coverage is invitation-only. Apple has to enable it for your channel, and it rejects live content from any channel it hasn’t approved.

Two components and an identifier

A live article is an ordinary Apple News Format document with one extra component. A live_container holds the updates, and each update is a live_post. A post can hold anything else an article can: body text, photos, video, embedded posts from social networks.

In the library these are LiveContainer and LivePost, in src/Document/Components/. Apple requires an identifier on both, unique within the article, so it’s the one argument each constructor takes, and an empty string throws. Both extend the plain Container class, which already knows how to hold and serialise child components. What they add is their ANF role and a stricter addComponent(). This is the container’s, from LiveContainer.php:

public function addComponent(Component $component): self
{
    if (!$component instanceof LivePost) {
        throw new InvalidArgumentException(
            'LiveContainer can only contain LivePost components, got ' . $component::class
        );
    }

    parent::addComponent($component);
    return $this;
}

Checking before Apple does

Apple validates every document it receives, and a broken one comes back with an error code and the key path of the field at fault. That’s useful, but for a live article it comes late. The mistake surfaces during the coverage, when someone is waiting for an update to appear, and the error points at a position in the JSON rather than at the code that built it.

So the library refuses the mistakes that are easiest to make in code: an empty identifier, anything other than a post inside the container, and a post or a container put directly inside a post. Each one throws an InvalidArgumentException as the component is added, so the stack trace points at the line that built it. LivePost.php has the second half of that:

if ($component instanceof LivePost || $component instanceof LiveContainer) {
    throw new InvalidArgumentException(
        'LivePost cannot contain another LivePost or LiveContainer'
    );
}

The check only looks at direct children. A post wrapped in a plain Container inside another post gets through, and Apple’s API has to catch that one. The docblock says so. A test checks that a plain container inside a post is allowed, because grouping a few components in a post is a perfectly good thing to do. Everything else about live articles is left to Apple, and the README says that too.

Newest first

Apple shows the posts in the order they appear in the document, and expects that order to be newest first. Container keeps its children in the order they’re added, so the newest post has to be added first. There’s no method for slipping a post in at the top.

That turns out not to matter. Every update to an article sends the whole document again, not just the new part. The simplest way to work is to keep the posts in your own storage and rebuild the container from them each time, newest first, giving each post the same identifier every time.

Starting and ending the coverage

Whether an article is live isn’t part of article.json. It’s request metadata called liveCoverageState, sent in the metadata part of the create and update requests. It has three values, which are the three cases of the LiveCoverageState enum: NONE, LIVE and COMPLETED. Apple accepts only the moves from NONE to LIVE and from LIVE to COMPLETED, and the article needs LIVE for the LIVE badge to show.

The metadata is built with ArticleMetadata, the same builder that handles sponsored content and maturity ratings. setLiveCoverageState() takes the enum, and toArray() includes only the fields that were set.

An update also needs the article’s current revision. updateArticle() takes the revision as an argument and adds it to whatever metadata you pass, in AppleNewsClient.php:

// Revision is required for updates
$meta = $metadata ?? [];
$meta['revision'] = $revision;
$builder->addMetadata($meta);

The multipart builder wraps the metadata in a data key, so both values land in the same object, as in Apple’s example. A test checks the exact bytes of that part when the coverage ends: {"data":{"liveCoverageState":"COMPLETED","revision":"rev-002"}}.

Two kinds of live

There’s a name clash to watch for. When you read an article back, Apple reports its processing state, and one of those states is also called LIVE. That LIVE means the article has finished processing and is visible in News. It says nothing about live coverage.

The library keeps the two apart as two enums: ArticleState for what Apple reports, and LiveCoverageState for what you send. They’re different types, so PHP won’t accept one where the other is expected. ArticleState::isLive() is true for LIVE and for PROCESSING_UPDATE, because in both some version of the article is visible. So the two answer different questions: whether readers can see the article, and whether the coverage is still running.

Keeping up with updates

Live coverage means frequent updates, and each one needs the revision from the response before it. The client methods return the decoded response as an array. ArticleResponse::fromApiResponse() turns that into a typed object, with id, revision, state and any warnings as properties.

Create and update responses also say how busy Apple is. getThrottling() returns a Throttling object with the estimated delay before the next request, the queue size and the quota left. It has two helpers, shouldDelay() and isQuotaLow(), which is worth checking between posts when updates come quickly. If someone else has updated the article in between, Apple answers with a 409 conflict, and you fetch the article for its fresh revision and try again.

Using it

composer require tomgould/apple-news-api guzzlehttp/guzzle guzzlehttp/psr7

With a client set up as in the README, this starts the coverage and later ends it:

use TomGould\AppleNews\Document\Article;
use TomGould\AppleNews\Document\Components\{Body, LiveContainer, LivePost, Title};
use TomGould\AppleNews\Enum\LiveCoverageState;
use TomGould\AppleNews\Request\ArticleMetadata;
use TomGould\AppleNews\Response\ArticleResponse;

// $posts maps each post's identifier to its text, newest first.
function buildArticle(array $posts): Article
{
    $live = new LiveContainer('live-coverage');
    foreach ($posts as $id => $text) {
        $live->addComponent((new LivePost($id))->addComponent(new Body($text)));
    }

    return Article::create(identifier: 'election-night', title: 'Election night', language: 'en')
        ->addComponent(new Title('Election night'))
        ->addComponent($live);
}

$posts = ['post-1' => 'Polls have closed.'];
$metadata = (new ArticleMetadata())->setLiveCoverageState(LiveCoverageState::LIVE);
$response = ArticleResponse::fromApiResponse(
    $client->createArticle($channelId, buildArticle($posts), $metadata->toArray())
);

// Each new post: add it to the front of $posts, rebuild the article and
// call updateArticle() with the latest revision.

$metadata = (new ArticleMetadata())->setLiveCoverageState(LiveCoverageState::COMPLETED);
$client->updateArticle($response->id, $response->revision, buildArticle($posts), $metadata->toArray());

The documentation covers every class, and the code is on GitHub.