Skip to content

omnisense/omnipost

Omnipost: the social networks' family. Publish: one contract for publishing a post - a reel, a picture, a carousel - through a network's API, reading one's own feed, and the capabilities each network imposes. Share: plain share links for visitors (Facebook, X, Pinterest, LinkedIn, Bluesky, Mastodon, Threads, WhatsApp, Telegram, Reddit, e-mail, copy, share sheet) - no network script, no cookie, one JSON for Twig, Blade and React. And its Symfony bundle.

Floor
Family
Version
1.x
Verified for real
Its README does not say
Licence
MIT
composer require omnisense/omnipost:1.x-dev

Omnipost is the PHP family for the social networks, with two contracts. Publishing: a site writes one canonical post - a reel, a picture, a carousel - with its caption, its hashtags and a title for the networks that want one; checks it against what each network accepts before anything is sent; publishes it on each; follows each publication until it is out; and reads its own account and feed back for a wall on the site. Each network is a package of its own (omnipost/instagram, omnipost/youtube) that turns those calls into the network's API. Sharing: plain share links for a page's visitors - each network's own share page, an e-mail, a copied link, the device's share sheet - with no network script, no cookie and no credential, and one JSON for Twig, Blade and React (Share links). The share links came from omnisense/omnishare, merged into this package on 2026-11-09.

$post = Post::reel(Media::video($url, $path, 42.0, 1080, 1920), 'Strauss, tonight.', null, ['violin'])
    ->withVariant(new Variant(Platform::YOUTUBE, title: 'Strauss, Sonata'));

$validator->validate($post->for(Platform::INSTAGRAM), $instagram->capabilities());   // list<Violation>, nothing sent
$publication = $instagram->publish($post->for(Platform::INSTAGRAM));                 // PENDING
$publication = $instagram->status($publication->id);                                 // PROCESSING, then PUBLISHED
$youtube->feed(null, 5)->items;                                                      // the channel's latest videos
$instagram->refresh();                                                               // 60 more days for the token

// use Omnipost\Share;
$share = Share\Registry::defaults()->share(new Share\Shareable($pageUrl, 'Strauss, tonight'), ['facebook', 'x', 'email']);
$share->toArray();                                                                   // the links a visitor shares the page with

It is about publishing, reading one's own account and sharing a page, not listening to a catalogue (that is omnisense/omnisong). The tokens stay with the site: a provider is built from the options it is given, refreshes and exchanges tokens when asked, and hands the Token back for the site to store.

The core needs no framework: it requires symfony/http-client-contracts and nothing else; each provider package requires symfony/http-client, a library that stands alone. A Symfony bundle is included for applications that want the wiring done (Symfony).

Published as omnisense/omnipost, formerly glitchr/omnipost and omniscient/omnipost: the package replaces both names, so a composer.json that still requires one of them resolves to this package and never installs two. The namespace is Omnipost\; the providers keep the omnipost/ vendor and the Omnipost\<Network>\ namespace. omnisense/omnishare is not replaced: its namespace changed (Omnishare\ is Omnipost\Share\), so an application that used it changes its use lines and requires this package instead (Share links).

When to use it

  • A musician's, a venue's or a shop's site that posts the same reel on Instagram and YouTube from its back office, with one caption and a variant per network where needed.
  • A back office that must tell the editor, before anything is sent, why a network would refuse a post (a 16:9 reel on Instagram, a Short of four minutes, a caption too long).
  • A wall of the account's latest posts or videos on the site, cached from the account's own feed.

It does not schedule: no network here takes a publication time except YouTube, and a site that wants "post at 18:00" runs the publication at 18:00 itself (a cron, a delayed message). It does not store tokens or publications: that is the application's job. It reads one's own account only, not anyone else's posts, and it does not distribute releases to streaming services (DistributorInterface is declared and implemented by no provider).

Providers

Two packages. The capabilities are what each provider's code answers (its capabilities() and the interfaces it implements), checked by its tests on recorded answers.

Package Name Publishes Kinds Status Delete Feed Token OAuth
omnipost/instagram instagram yes, from public URLs reel, image, carousel (2-10), story yes, publishes the container when ready no yes long-lived (60 days), refresh() authorizationUrl(), exchange()
omnipost/youtube youtube yes, uploads the file reel (a Short), video yes yes yes, with a key or the token access token drawn from a refresh token authorizationUrl(), exchange()

Platform lists ten networks (Instagram, YouTube, Facebook, TikTok, Threads, X, LinkedIn, Bluesky, Mastodon, Pinterest): only the first two have a provider.

The contracts per network

A provider package, omnipost/<network>, implements the contracts that need its network's API: publishing (PublisherInterface), reading the account (FeedInterface), tokens (Auth\RefreshableInterface, Auth\OAuthInterface). The share contract (Share\NetworkInterface) needs no API: a share network is an address template, so every one of them is in the core's table (Share\Registry::defaults()), and a provider never carries one. A network that has both keeps its share link in the core and gets publishing from its provider.

Network Share link (the core) Publishing, feed (a provider)
Instagram none: no share page on the web omnipost/instagram
YouTube none: no share page on the web omnipost/youtube
TikTok none: no share page on the web none yet
Facebook facebook none yet (omnipost/facebook when a Meta app exists)
X, Threads, LinkedIn, Bluesky, Mastodon, Pinterest x, threads, linkedin, bluesky, mastodon, pinterest none yet: each has a publishing API, none is written
WhatsApp, Telegram, Reddit whatsapp, telegram, reddit not in Platform
E-mail, the clipboard, the device's share sheet email, copy, native -

How this manual is organised

Propose a change docs/index.md

Installation

composer require omnisense/omnipost omnipost/instagram omnipost/youtube

Requirements

  • PHP 8.2 or later, with mbstring (the validator measures captions and titles with mb_strlen()).

  • omnisense/omnipost requires symfony/http-client-contracts (^3.0) and nothing else.

  • Each provider package (omnipost/instagram, omnipost/youtube) requires omnisense/omnipost and symfony/http-client (^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework.

  • For the Symfony bundle: symfony/config, symfony/dependency-injection and symfony/http-kernel (6.4, 7 or 8). They are suggested, not required: a Symfony application has them.

Omnipost runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).

Plain PHP

<?php // bare.php

require __DIR__.'/vendor/autoload.php';

use Omnipost\Instagram\InstagramProviderFactory;
use Omnipost\Model\Media;
use Omnipost\Model\Post;
use Omnipost\Model\Variant;
use Omnipost\Platform;
use Omnipost\Registry;
use Omnipost\Validator;
use Omnipost\YouTube\YouTubeProviderFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// The HTTP client the providers call with: in an application, HttpClient::create(). Here, one that
// answers as YouTube's Data API does, so that the script runs as it is, without a key.
$http = new MockHttpClient(static fn (string $method, string $url) => new MockResponse(json_encode(match (parse_url($url, PHP_URL_PATH)) {
    '/youtube/v3/channels' => ['items' => [['id' => 'UCabc', 'snippet' => ['title' => 'Claire', 'customUrl' => '@claireviolin'], 'statistics' => ['subscriberCount' => '4200', 'videoCount' => '36'], 'contentDetails' => ['relatedPlaylists' => ['uploads' => 'UUabc']]]]],
    '/youtube/v3/playlistItems' => ['items' => [['snippet' => ['title' => 'Strauss, Sonata', 'resourceId' => ['videoId' => 'shortA']], 'contentDetails' => ['videoId' => 'shortA', 'videoPublishedAt' => '2026-09-30T18:00:00Z']]]],
    '/youtube/v3/videos' => ['items' => [['id' => 'shortA', 'contentDetails' => ['duration' => 'PT58S'], 'statistics' => ['viewCount' => '1200']]]],
}), ['response_headers' => ['Content-Type' => 'application/json']]));

$registry = new Registry([new InstagramProviderFactory($http), new YouTubeProviderFactory($http)], [
    'instagram' => ['factory' => 'instagram'],   // options: access_token, app_id, app_secret - none needed to check a post
    'youtube' => ['factory' => 'youtube', 'options' => ['api_key' => 'youtube_test_not_a_real_key', 'channel_id' => 'UCabc']],
]);

// One canonical post, checked against what each network accepts before anything is sent
$validator = new Validator();
$reel = Post::reel(Media::video('https://site.example/reel.mp4', null, 42.0, 1080, 1920), 'Strauss, tonight.', null, ['violin']);
foreach ([$reel, $reel->withVariant(new Variant(Platform::YOUTUBE, title: 'Strauss, Sonata'))] as $post) {
    foreach ($registry->publishers() as $publisher) {
        $violations = $validator->validate($post->for($publisher->getPlatform()), $publisher->capabilities());
        printf("%-10s %s\n", $publisher->getName(), $violations ? implode(' ', $violations) : 'accepted');
    }
}

// The channel's latest videos, for a wall on the site
$youtube = $registry->get('youtube');
echo "\n", $youtube->account()->username, ', ', $youtube->account()->followers, " subscribers\n";
foreach ($youtube->feed(null, 5)->items as $item) {
    echo $item->publishedAt->format('Y-m-d'), ' ', $item->kind->value, ' ', $item->caption, ' (', $item->duration, ' s, ', $item->views, ' views) ', $item->permalink, "\n";
}
$ php bare.php
instagram  accepted
youtube    title: A title is required.
instagram  accepted
youtube    accepted

@claireviolin, 4200 subscribers
2026-09-30 reel Strauss, Sonata (58 s, 1200 views) https://www.youtube.com/watch?v=shortA

(run on 2026-10-05 in an empty directory, after the composer require above, and again on 2026-10-26 on the packages' checkouts with the same output; the channel and its video are made up - every network asks for an account's key or token, so the script answers in YouTube's place. With HttpClient::create(), your key and your channel's id, the same lines read the real channel.)

That is all there is to it:

  • a factory per provider package (InstagramProviderFactory, YouTubeProviderFactory), which takes the HTTP client to call with - the application's, a MockHttpClient in a test; with none given it makes its own (HttpClient::create());
  • the registry, built by hand from the factories and the providers' options, by name: get(), publishers(), feeds(), and create($name, ['access_token' => $stored]) for a token kept in a database;
  • the validator, which lists what a network would refuse before anything is sent;
  • the providers it gives: publish(), status(), delete(), capabilities() (PublisherInterface), account(), feed() (FeedInterface), refresh() (Auth\RefreshableInterface), authorizationUrl() and exchange() (Auth\OAuthInterface).

No class of a framework is loaded on the way - a test of this package checks it in a process of its own (Tests/BareTest.php), and so does docker compose run --rm omnipost bare (harness).

Publishing

use Omnipost\Exception\InvalidPostException;
use Omnipost\Instagram\InstagramProviderFactory;
use Symfony\Component\HttpClient\HttpClient;

$instagram = (new InstagramProviderFactory(HttpClient::create()))->create(['access_token' => getenv('INSTAGRAM_ACCESS_TOKEN')]);

try {
    $publication = $instagram->publish($post->for(Platform::INSTAGRAM));   // PENDING: Instagram fetches and transcodes
} catch (InvalidPostException $e) {
    $e->violations;                                                        // refused before anything was sent
}
while (!$publication->state->isFinal()) {
    sleep(5);
    $publication = $instagram->status($publication->id);                  // PROCESSING, then PUBLISHED with its permalink
}

The media must be at a public URL the network can fetch (Instagram), or a local file the provider uploads (YouTube: Media::video($url, $path, ...); with no path, YouTube's provider fetches the URL itself first). The whole flow, network by network: Publishing.

The tokens stay with the site

$token = $instagram->refresh();              // a fresh long-lived token: store $token->toArray()
$url = $youtube->authorizationUrl('https://site.example/callback', $state);
$token = $youtube->exchange($_GET['code'], 'https://site.example/callback');   // the refresh token to keep

Token says when it dies. A provider whose credential is missing still builds: capabilities() and authorizationUrl() need none, and a call that needs one throws InvalidConfigException.

In a framework

  • Symfony: Omnipost\Bridge\Symfony\OmnipostBundle registers the factories on the application's http_client, builds the registry from config/packages/omnipost.yaml and makes each provider injectable by its name: see Symfony. Its components (symfony/config, symfony/dependency-injection, symfony/http-kernel) are not required by this package: a Symfony application has them.
  • Laravel: there is no bridge package; a service provider builds the registry once: Laravel.
  • Any other: build the Registry once, where the framework builds its services (a container definition, a factory), as the script above does.

Errors

The whole list, and what each provider maps to which: Errors.

Exception When
InvalidPostException the post breaks the network's capabilities(): nothing was sent ($violations)
InvalidConfigException a provider not configured, a factory not installed, a credential missing for the call
UnauthorizedException the token is dead or refused: refresh it, or connect the account again
NotSupportedException the network's API cannot do that (delete a post on Instagram)
ProviderException any other refusal of the network

All implement Omnipost\Exception\OmnipostException.

Propose a change docs/installation.md

Symfony

Omnipost runs without a framework (Installation); in a Symfony application its bundle does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8) - are not required by omnisense/omnipost: the application has them, and nothing of them is loaded outside Symfony. The bundle is part of the core (Omnipost\Bridge\Symfony\OmnipostBundle); omnibridge has no Omnipost bridge, and none is needed.

Registering the bundle

There is no Flex recipe: add the bundle by hand.

// config/bundles.php
return [
    // ...
    Omnipost\Bridge\Symfony\OmnipostBundle::class => ['all' => true],
];

Configuration

# config/packages/omnipost.yaml
omnipost:
    providers:                     # by name: a factory and its options
        instagram: { factory: instagram, options: { access_token: '%env(default::INSTAGRAM_ACCESS_TOKEN)%', app_id: '%env(default::INSTAGRAM_APP_ID)%', app_secret: '%env(default::INSTAGRAM_APP_SECRET)%' } }
        youtube:   { factory: youtube, options: { api_key: '%env(default::YOUTUBE_API_KEY)%', channel_id: '%env(default::YOUTUBE_CHANNEL_ID)%' } }

The whole tree, from the bundle's configure():

Key Type Default
omnipost.providers map, keyed by the provider's name {} the site's networks
omnipost.providers.<name>.factory string, required, not empty - a factory's name: instagram, youtube, or an application's own factory's getName()
omnipost.providers.<name>.options any value (a map) [] handed as they are to the factory's create() (Configuration)

Processed with nothing set, the tree is {"providers": []}; a provider without its factory is refused when the container compiles (The child config "factory" under "omnipost.providers.ig" must be configured.). The options are not validated then: no option of either shipped provider is required to build it, and a credential left empty (%env(default::...)% with the variable unset) is the same as not given - it shows only when a call needs it, as an InvalidConfigException.

Services

Each provider package the bundle knows - omnipost/instagram (Omnipost\Instagram\InstagramProviderFactory) and omnipost/youtube (Omnipost\YouTube\YouTubeProviderFactory) - has its factory registered when its class exists, built on the application's http_client service, or with none when the application has no such service (the factory's API client then makes its own).

Service id Class Autowiring
Omnipost\Registry Registry Registry $registry every configured provider by name. Public.
Omnipost\Validator Validator Validator $validator a post against a network's capabilities
omnipost.provider.<name> ProviderInterface ProviderInterface $<name>, PublisherInterface $<name>, FeedInterface $<name> one provider, built by Registry::get('<name>') the first time it is injected
the factories their class by their class tagged omnipost.provider_factory
use Omnipost\FeedInterface;
use Omnipost\PublisherInterface;
use Omnipost\Registry;
use Omnipost\Validator;

final class SocialController
{
    public function __construct(
        private readonly PublisherInterface $instagram,   // the "instagram" provider, as a publisher
        private readonly FeedInterface $youtube,          // the "youtube" provider, as a feed
        private readonly Registry $registry,
        private readonly Validator $validator,
    ) {
    }
}

The argument's name selects the provider; the type says what it is used for. Each provider is aliased under all three types whatever it implements: a provider that is not a publisher, injected as PublisherInterface, fails at injection with a type error. Auth\OAuthInterface and Auth\RefreshableInterface have no alias: inject the provider as one of the three types and check instanceof, or ask the registry. A provider's name that is not a valid PHP variable name is still reachable by its service id or by the registry.

Nothing is built when the container compiles: a provider is built the first time it is asked for. So a site boots before its accounts are connected; $registry->has('instagram') says a provider is declared, a call inside try { } catch (InvalidConfigException) whether it is configured.

A token kept in a database rather than in the environment: $registry->create('instagram', ['access_token' => $stored]) builds a provider afresh with those options over the configured ones, not kept by the registry (Tokens).

An application's own provider

A class of the application that implements Omnipost\ProviderFactoryInterface (usually by extending Omnipost\ProviderFactory) is autoconfigured: tagged omnipost.provider_factory, it joins the registry, and its getName() can be named as a factory:

omnipost:
    providers:
        fediverse:
            factory: mastodon     # App\Social\MastodonProviderFactory::getName()
            options: { token: '%env(MASTODON_TOKEN)%' }

Writing a provider says what it must do.

The bundle registers no command, event, route, controller or Twig function. Publishing takes time (Instagram transcodes, YouTube uploads): run publish() and the status() polling from a message handler or a command rather than in a web request (Publishing).

Propose a change docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnipost provider, and there is no other Laravel package for it. The registry is plain PHP: a service provider of the application builds it once, from a config file, with a symfony/http-client client (both provider packages require it; Laravel's own HTTP client is Guzzle's, which the providers do not take).

// config/omnipost.php
return [
    'providers' => [
        'instagram' => ['factory' => 'instagram', 'options' => [
            'access_token' => env('INSTAGRAM_ACCESS_TOKEN'),
            'app_id' => env('INSTAGRAM_APP_ID'),
            'app_secret' => env('INSTAGRAM_APP_SECRET'),
        ]],
        'youtube' => ['factory' => 'youtube', 'options' => [
            'api_key' => env('YOUTUBE_API_KEY'),
            'channel_id' => env('YOUTUBE_CHANNEL_ID'),
            'client_id' => env('YOUTUBE_CLIENT_ID'),
            'client_secret' => env('YOUTUBE_CLIENT_SECRET'),
            'refresh_token' => env('YOUTUBE_REFRESH_TOKEN'),
        ]],
    ],
];
// app/Providers/OmnipostServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnipost\Instagram\InstagramProviderFactory;
use Omnipost\Registry;
use Omnipost\Validator;
use Omnipost\YouTube\YouTubeProviderFactory;
use Symfony\Component\HttpClient\HttpClient;

final class OmnipostServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create();

            return new Registry([
                new InstagramProviderFactory($http),
                new YouTubeProviderFactory($http),
            ], config('omnipost.providers'));
        });
        $this->app->singleton(Validator::class);
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry and ask for a provider by name:

use Omnipost\Platform;
use Omnipost\Registry;

final class PublishReel   // a queued job: publishing takes time
{
    public function handle(Registry $registry): void
    {
        $instagram = $registry->get('instagram');
        $publication = $instagram->publish($this->post->for(Platform::INSTAGRAM));
        // store $publication->id, then poll status() from a later job
    }
}

Differences from the Symfony bundle: list in the provider every factory the application uses (a hand-built registry knows only the factories it is given), and there is no injection of one provider by its argument's name - ask the registry. An env() left unset is null, which the factories treat as not given. Configuration caching (php artisan config:cache) keeps working: the options are plain arrays read through config(). This page was written from the registry's API; it was not run in a Laravel application.

Propose a change docs/laravel.md

Concepts

Providers and what they do

A provider is one network, behind Omnipost\ProviderInterface:

Method
getName(): string the name it is configured as: instagram, youtube
getPlatform(): Platform the network

What it does is said by the other interfaces it implements:

Interface Methods Implemented by
Omnipost\PublisherInterface capabilities(): Capabilities, publish(Post $post): Publication, status(string $publicationId): Publication, delete(string $publicationId): void Instagram, YouTube
Omnipost\FeedInterface account(): Account, feed(?string $cursor = null, int $limit = 25): Feed (newest first) Instagram, YouTube
Omnipost\Auth\RefreshableInterface token(): ?Token, refresh(): Token Instagram, YouTube
Omnipost\Auth\OAuthInterface authorizationUrl(string $redirectUri, string $state, array $scopes = []): string, exchange(string $code, string $redirectUri): Token Instagram, YouTube
Omnipost\DistributorInterface createRelease(array $release): string, deliver(string $releaseId): void, deliveryStatus(string $releaseId): array none: reserved for delivering releases to streaming services through a distributor (DDEX)

The contract of a publisher:

  • publish() checks the post against the network first; a post it would refuse is an InvalidPostException listing every violation, and nothing is sent.
  • It answers with the network's id for the publication and a state that may not be final (PENDING, PROCESSING): status($id) reads it again until it is. The id can change on the way (Instagram's container id becomes the media's): keep the latest one.
  • delete() takes the post down, or throws NotSupportedException where the network's API cannot.

Both shipped providers also have a public validate(Post $post): list<Violation>, not part of an interface: the core's Validator against their capabilities, plus the rules that depend on the post's kind. It is what their publish() runs.

Factories and their Config

A provider is built by its factory, Omnipost\ProviderFactoryInterface: getName(): string (the name providers are configured with) and create(array $options = []): ProviderInterface. Both packages extend Omnipost\ProviderFactory:

Member
__construct(?HttpClientInterface $http = null) the HTTP client the provider calls with; null: the provider package makes its own (HttpClient::create())
getName() the omnipost.factory_name its populate() sets
create($options) createConfig($options), checks every key of omnipost.required_options is filled, then build()
createConfig($options): Config the options without those that are null or '', with the factory's defaults under them
populate(Config $config) (abstract, protected) the name, the required options, the defaults
build(Config $config) (abstract, protected) the provider

Dropping empty options means an environment variable left unset (%env(default::X)%, env('X')) is as if not given: the default stands. Neither shipped factory requires an option, by design: a provider without its token still answers what needs none (capabilities(), authorizationUrl()), and a call that needs a credential and finds none throws InvalidConfigException naming it.

Omnipost\Config is an ArrayObject of the options: defaults(array) (sets each key not set), validateNotEmpty(array $keys) (The "<factory>" provider needs: <keys>.), get($key, $default).

The registry

Omnipost\Registry holds the providers by name:

$registry = new Registry(
    [new InstagramProviderFactory($http), new YouTubeProviderFactory($http)],
    ['instagram' => ['factory' => 'instagram', 'options' => ['access_token' => '...']]],
);
Method
get(string $name): ProviderInterface the provider, built once and kept. A name not configured or a factory not given is an InvalidConfigException: No "x" provider; configured: instagram., No "x" factory for the "y" provider; installed: instagram, youtube.
create(string $name, array $overrides = []): ProviderInterface a provider built afresh, the configured options with $overrides over them (array_replace): a token kept in a database. Not kept: get() still gives the configured one
has(string $name): bool whether the name is configured
options(string $name): array its configured options ([] when none or not configured)
names(): list<string> the configured names, in order
all(): array<string, ProviderInterface> every configured provider, built
publishers(): list<PublisherInterface>, feeds(): list<FeedInterface> those among them that publish, that have a feed (builds them all)
factories(): list<string> the factories' names

Posts

Omnipost\Model\Post is the canonical post, immutable:

Property Type Default
kind PostKind - what it is
media list<Media> - the pictures or videos, in order
caption string '' without the hashtags
title ?string null for the networks that want one (YouTube)
tags list<string> [] hashtags, with or without #
coverUrl ?string null a still for a video (Instagram's cover_url)
scheduledAt ?\DateTimeImmutable null a publication time, where the network takes one (YouTube)
variants array<string, Variant> [] platform value => what that network gets instead
options array<string, mixed> [] provider-specific extras (privacy, share_to_feed...): a variant's win
link ?string null a link to carry (the site's page)
Method
Post::reel(Media $video, string $caption = '', ?string $title = null, array $tags = []) a REEL, its cover the video's coverUrl
Post::image(Media $image, string $caption = '', array $tags = []) an IMAGE
Post::carousel(array $media, string $caption = '', array $tags = []) a CAROUSEL
withVariant(Variant $variant) a copy with that network's variant (replacing one for the same network)
withScheduledAt(?\DateTimeImmutable $at) a copy with that time
variant(Platform $platform): ?Variant
for(Platform $platform): Post the post as that network receives it: the variant's media, caption, title, tags and cover over the post's, its options over the post's options ($variant->options + $post->options), no variants left. Kind, schedule and link stay the post's
text(): string the caption, a blank line, the hashtags each with one #, space-separated; trimmed
video(): ?Media the first video among the media

Post::$link is written into the caption, on its own line before the hashtags, by a network whose captions make it a link (Capabilities::$linkInCaption: YouTube's description), and left out by one whose captions do not (Instagram); the validator counts it in the caption's length there.

Publish $post->for($platform), not $post: a provider reads the post's own fields, never its variants.

Omnipost\Model\Variant: platform (Platform), and caption, title, tags, media, coverUrl (each null to keep the post's), options ([]).

Omnipost\Model\PostKind, string-backed: REEL (reel: a vertical short video, the one every network takes), IMAGE (image), CAROUSEL (carousel), VIDEO (video), STORY (story).

Media

Omnipost\Model\Media is a picture or a video to post. The networks that fetch it themselves (Instagram) need url to be public - a signed, temporary URL will do; path is the local file, for the providers that upload (YouTube).

Property Type Default
kind MediaKind - IMAGE (image) or VIDEO (video)
url string - public URL
path ?string null local file
mime ?string null video/mp4...: the format check needs it
width, height ?int null pixels: the ratio and minimum-size checks need them
duration ?float null seconds
size ?int null bytes
coverUrl ?string null a still for a video
alt ?string null alternative text (Instagram pictures)

Media::image($url, $path, $width, $height, $alt) and Media::video($url, $path, $duration, $width, $height, $coverUrl) leave mime and size null: the validator checks only what is known, so build with new Media(...) to have the format and size checked. aspectRatio(): ?string is the reduced ratio (9:16, 16:9; null when the size is unknown), ratio(): ?float width / height.

Capabilities and violations

Omnipost\Model\Capabilities is what a network accepts:

Property Type Default
kinds list<PostKind> - the kinds it takes
minDuration, maxDuration ?float null a video's bounds, seconds
aspectRatios list<string> [] 9:16, 4:5...; empty: any
ratioTolerance float 0.01 a fraction of the ratio (1 %)
maxVideoSize, maxImageSize ?int null bytes
captionMaxLength ?int null characters, hashtags included
maxTags ?int null
titleRequired bool false
titleMaxLength ?int null
mediaMaxCount int 1
videoFormats, imageFormats list<string> [] mime types or extensions; empty: any
minWidth, minHeight ?int null pixels
scheduling bool false whether the network takes a publication time
linkInCaption bool true declared; read by no check

supports(PostKind $kind): bool; acceptsRatio(float $ratio): bool (true when the list is empty, or the ratio is within the tolerance of one of them). The values of each network: Validating a post.

Omnipost\Model\Violation: path (caption, media[0].duration...) and message, readable; (string) $violation is path: message.

Publications

Omnipost\Model\Publication is what became of a post on one network:

Property Type
platform Platform
id string the network's id: what status() and delete() take
state PublicationState
permalink ?string once published (YouTube gives it from the start)
error ?string the network's reason, when failed
publishedAt ?\DateTimeImmutable
raw array the network's own answer, for the record

isPublished(), isFailed(). Omnipost\Model\PublicationState: PENDING (pending: handed to the network, not processed yet; a scheduled video), PROCESSING (processing: transcoding), PUBLISHED (published), FAILED (failed); isFinal() is true for the last two.

Accounts and feeds

Omnipost\Model\Account: platform, id, username, and name, url, pictureUrl, followers (?int; null when hidden), posts (?int), kind (?string: Instagram's account type).

Omnipost\Model\Feed: items (list<FeedItem>, newest first) and next (?string: the cursor of the next page, null on the last one).

Omnipost\Model\FeedItem:

Property Type
id string
kind PostKind
permalink string the post's page
url ?string the media itself (Instagram), the player to embed (YouTube)
thumbnailUrl ?string
caption ?string YouTube: the title
publishedAt ?\DateTimeImmutable
duration ?float seconds (YouTube)
children list<FeedItem> a carousel's items
likes, comments, views ?int as the network gives them
raw array the network's own answer

Tokens

Omnipost\Model\Token:

Member
accessToken (string), expiresAt (?\DateTimeImmutable), refreshToken (?string), scopes (list<string>), accountId (?string)
isExpired(?\DateTimeImmutable $now = null): bool past expiresAt; never when it is unknown
isExpiring(int $days = 10, ?\DateTimeImmutable $now = null): bool within $days of expiresAt: time to refresh
toArray(): array access_token, expires_at (ATOM), refresh_token, scopes, account_id, unknown ones left out: what a site stores
Token::fromArray(array $data) back

Platforms

Omnipost\Platform, string-backed, with label(): INSTAGRAM (instagram, Instagram), YOUTUBE (youtube, YouTube), FACEBOOK (facebook), TIKTOK (tiktok, TikTok), THREADS (threads), X (x), LINKEDIN (linkedin, LinkedIn), BLUESKY (bluesky), MASTODON (mastodon), PINTEREST (pinterest). Only Instagram and YouTube have a provider; the others can carry variants for an application's own providers.

Propose a change docs/concepts.md

Publishing

One post, every network

<?php // publish.php

require __DIR__.'/vendor/autoload.php';

use Omnipost\Exception\InvalidPostException;
use Omnipost\Instagram\InstagramProviderFactory;
use Omnipost\Model\Media;
use Omnipost\Model\MediaKind;
use Omnipost\Model\Post;
use Omnipost\Model\Variant;
use Omnipost\Platform;
use Omnipost\YouTube\YouTubeProviderFactory;
use Symfony\Component\HttpClient\HttpClient;

$http = HttpClient::create();

$video = new Media(MediaKind::VIDEO, 'https://site.example/media/reel.mp4', __DIR__.'/reel.mp4', 'video/mp4', 1080, 1920, 42.0, null, 'https://site.example/media/reel.jpg');
$post = Post::reel($video, 'Strauss, tonight at the Philharmonie.', null, ['violin', 'strauss'])
    ->withVariant(new Variant(Platform::YOUTUBE, title: 'Strauss, Sonata in E-flat - live', options: ['privacy' => 'unlisted']));

$instagram = (new InstagramProviderFactory($http))->create(['access_token' => getenv('INSTAGRAM_ACCESS_TOKEN')]);
$publication = $instagram->publish($post->for(Platform::INSTAGRAM));
echo $publication->state->value, ' ', $publication->id, "\n";
while (!$publication->state->isFinal()) {
    sleep(5);
    $publication = $instagram->status($publication->id);
    echo $publication->state->value, ' ', $publication->id, ' ', $publication->permalink, "\n";
}

$youtube = (new YouTubeProviderFactory($http))->create([
    'client_id' => getenv('YOUTUBE_CLIENT_ID'),
    'client_secret' => getenv('YOUTUBE_CLIENT_SECRET'),
    'refresh_token' => getenv('YOUTUBE_REFRESH_TOKEN'),
]);
$publication = $youtube->publish($post->for(Platform::YOUTUBE));
echo $publication->state->value, ' ', $publication->id, ' ', $publication->permalink, "\n";

try {
    $youtube->publish($post);   // the canonical post: no title
} catch (InvalidPostException $e) {
    echo $e->getMessage(), "\n";
}

Run on 2026-10-26 on a MockHttpClient answering as Instagram and YouTube document it (the ids are made up; no network was called) and test credentials in place of the environment's, with the mock's log of each request (>, tokens masked):

> POST https://graph.instagram.com/v23.0/me/media  media_type=REELS&video_url=https://site.example/media/reel.mp4&caption=Strauss, tonight at the Philharmonie.

#violin #strauss&cover_url=https://site.example/media/reel.jpg&share_to_feed=true&access_token=***
pending 17900000000000001
> GET https://graph.instagram.com/v23.0/17900000000000001?fields=status_code,status&access_token=***
processing 17900000000000001 
> GET https://graph.instagram.com/v23.0/17900000000000001?fields=status_code,status&access_token=***
> POST https://graph.instagram.com/v23.0/me/media_publish  creation_id=17900000000000001&access_token=***
> GET https://graph.instagram.com/v23.0/18000000000000002?fields=id,permalink,timestamp,media_type&access_token=***
published 18000000000000002 https://www.instagram.com/reel/ABC/
> POST https://oauth2.googleapis.com/token  client_id=cid.apps.googleusercontent.com&client_secret=secret&grant_type=refresh_token&refresh_token=1//refresh
> POST https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status  {"snippet":{"title":"Strauss, Sonata in E-flat - live","description":"Strauss, tonight at the Philharmonie.\n\n#violin #strauss","tags":["violin","strauss"]},"status":{"privacyStatus":"unlisted","selfDeclaredMadeForKids":false}}
> PUT https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&upload_id=xyz
processing dQw4w9WgXcQ https://www.youtube.com/watch?v=dQw4w9WgXcQ
[youtube] title: A title is required.

What it shows:

  • $post->for($platform) is what each network gets: YouTube's variant brings the title and the privacy option; Instagram gets the canonical caption.
  • Both networks get the caption with the hashtags appended (Post::text()); YouTube also gets the tags as tags, without #.
  • Instagram's id changes from the container's (17900000000000001) to the media's (18000000000000002) when it is published.
  • YouTube draws an access token from the refresh token once, opens a resumable upload with the metadata, then sends the file.
  • The canonical post, without the variant, has no title: YouTube refuses it before any request.

Instagram, in three steps

  1. publish() validates the post, then creates a container: POST /<user>/media with the media's public URL - Instagram fetches it. Per kind:

    Kind Container fields
    REEL media_type=REELS, video_url, caption, cover_url (the post's cover, else the video's), share_to_feed (the post's option, else the provider's), thumb_offset (the post's option)
    IMAGE image_url, caption, alt_text (the media's alt)
    STORY media_type=STORIES, video_url or image_url - no caption
    CAROUSEL one container per item (is_carousel_item=true, media_type=VIDEO + video_url for a video, image_url for a picture), then media_type=CAROUSEL, children (their ids, comma-separated), caption
    VIDEO refused by the validator (not in Instagram's kinds)

    Empty fields are left out. It answers PENDING with the container's id.

  2. status($containerId) reads status_code: FINISHED publishes it (step 3); ERROR and EXPIRED are FAILED with Instagram's status as the error; anything else (IN_PROGRESS) is PROCESSING.

  3. On FINISHED, POST /<user>/media_publish with creation_id, then the media is read: the answer is PUBLISHED with the media's id, its permalink and its time. A container already published (PUBLISHED) answers PUBLISHED with the container's id and no permalink.

status() also takes a media's id: Instagram answers error code 100 for a container's fields on a media, and the media is read instead (PUBLISHED, its permalink).

Step 3 runs inside status(). Two workers polling the same container at once would publish it twice: hold a lock (symfony/lock) around status() for one publication.

delete() throws NotSupportedException: Instagram's API cannot delete a post. Instagram also caps the publications made through the API per account and per 24 hours; the provider does not read the cap (GET /<user>/content_publishing_limit).

YouTube, one upload

publish() validates the post, requires the channel's consent (an access token, or the client id, secret and refresh token; otherwise InvalidConfigException before the file is touched), then:

  1. the file: Media::$path when set (its size from the file), else Media::$url downloaded to a temporary file first (any status but 200 is a ProviderException); the content type is the media's mime, else the download's, and video/* when it is not a video type;

  2. POST <upload_uri>/videos?uploadType=resumable&part=snippet,status with the metadata:

    Field Value
    snippet.title the post's title
    snippet.description Post::text(): the caption and the hashtags
    snippet.tags the tags without # (left out when none)
    snippet.categoryId the post's category_id option, else the provider's (left out when none)
    status.privacyStatus private when scheduled; else the post's privacy option, else the provider's (private by default)
    status.selfDeclaredMadeForKids false
    status.publishAt the post's scheduledAt in UTC, when it is in the future
  3. PUT of the bytes to the URL YouTube answered, with a 300-second idle timeout and no overall limit.

The answer is the video: uploadStatus processed is PUBLISHED (or PENDING when private with a publishAt still to come), failed, rejected and deleted are FAILED with failureReason / rejectionReason, anything else (uploaded) PROCESSING. The permalink (https://www.youtube.com/watch?v=<id>) is set from the start.

A REEL is uploaded as any video is: YouTube decides whether it is a Short, from the file (vertical, at most three minutes). PUBLISHED means processed, not public: a private video is PUBLISHED too. The videos of an API project that has not passed Google's audit are locked private whatever privacy says; the channel's owner makes them public in YouTube Studio.

status($videoId) reads videos?part=status,snippet as the channel when it can (a key does not see a private video), and a video not found is a ProviderException (404, videoNotFound). delete($videoId) deletes the video as the channel.

Following a publication

Publishing takes from seconds to minutes. In an application, publish from a message handler or a command, store the publication's id and state, and poll status() later until isFinal(), keeping the id each answer gives:

use Omnipost\Model\Publication;
use Omnipost\Registry;
use Symfony\Component\Lock\LockFactory;

final class FollowPublication
{
    public function __construct(private readonly Registry $registry, private readonly LockFactory $locks)
    {
    }

    public function __invoke(string $provider, string $id): Publication
    {
        $lock = $this->locks->createLock('omnipost.'.$provider.'.'.$id);
        $lock->acquire(true);
        try {
            return $this->registry->get($provider)->status($id);   // store ->id and ->state
        } finally {
            $lock->release();
        }
    }
}

Scheduling

Only YouTube takes a publication time (Capabilities::$scheduling): withScheduledAt($at) uploads the video private with publishAt, and YouTube publishes it then (PENDING until that time). Instagram does not: the validator refuses a scheduled post for it (scheduledAt: Scheduling is not offered: the site must post it at that time itself.), and the site publishes at that time itself. A scheduledAt already past is ignored by YouTube's provider: the video goes out at once.

Propose a change docs/usage-publishing.md

Validating a post

Every network refuses some posts: a landscape reel on Instagram, a Short of four minutes, a caption too long. Omnipost\Validator lists what a network would refuse, by field, before anything is sent - for a back office to show next to the field. publish() runs the same check and throws InvalidPostException (with the list) instead of sending.

<?php // validate.php

require __DIR__.'/vendor/autoload.php';

use Omnipost\Instagram\InstagramProviderFactory;
use Omnipost\Model\Media;
use Omnipost\Model\MediaKind;
use Omnipost\Model\Post;
use Omnipost\Model\PostKind;
use Omnipost\Validator;
use Omnipost\YouTube\YouTubeProviderFactory;

$instagram = (new InstagramProviderFactory())->create();
$youtube = (new YouTubeProviderFactory())->create();

$landscape = new Media(MediaKind::VIDEO, 'https://site.example/clip.webm', null, 'video/webm', 1920, 1080, 2.5, 2 << 30);
$post = Post::reel($landscape, str_repeat('la ', 800), null, array_fill(0, 31, 'tag'));

foreach ($instagram->validate($post) as $violation) {
    echo 'instagram ', $violation, "\n";
}
foreach ($youtube->validate($post) as $violation) {
    echo 'youtube   ', $violation, "\n";
}
$square = Post::reel(Media::video('https://site.example/square.mp4', null, 30.0, 1080, 1080), 'Square');
foreach ($instagram->validate($square) as $violation) {
    echo 'instagram ', $violation, "\n";
}
$long = Post::reel(Media::video('https://site.example/long.mp4', null, 240.0, 1080, 1920), 'Long', 'A long one');
foreach ($youtube->validate($long) as $violation) {
    echo 'youtube   ', $violation, "\n";
}
$scheduled = Post::image(Media::image('https://site.example/a.jpg', null, 1080, 1350))->withScheduledAt(new DateTimeImmutable('+1 day'));
foreach ((new Validator())->validate($scheduled, $instagram->capabilities()) as $violation) {
    echo 'instagram ', $violation, "\n";
}
echo json_encode(array_map(fn ($k) => $k->value, $instagram->capabilities()->kinds)), ' ', json_encode(array_map(fn ($k) => $k->value, $youtube->capabilities()->kinds)), "\n";
$ php validate.php
instagram media[0].duration: 2.5 s, at least 3 s.
instagram media[0].size: 2 GB, at most 1 GB.
instagram media[0].format: video/webm is not accepted (accepted: video/mp4, video/quicktime, mp4, mov).
instagram media[0].ratio: 16:9 is not accepted (accepted: 9:16, 4:5, 1:1).
instagram caption: 2556 characters with the hashtags, at most 2200.
instagram tags: 31 hashtags, at most 30.
youtube   tags: 31 hashtags, at most 30.
youtube   title: A title is required.
instagram media[0].ratio: 1:1: a reel is 9:16.
youtube   media[0].duration: 240.0 s, at most 180 s for a Short.
instagram scheduledAt: Scheduling is not offered: the site must post it at that time itself.
["reel","image","carousel","story"] ["reel","video"]

(run on 2026-10-26, PHP 8.4, no network: nothing is called). No credential is needed: the providers are built with no option.

Two levels

  • Validator::validate(Post $post, Capabilities $c): list<Violation> checks what a Capabilities can say, the same for every network.
  • $provider->validate(Post $post) (Instagram and YouTube) adds what depends on the post's kind, and is what publish() runs. Call it rather than the validator when the provider is at hand.

Validate $post->for($platform), the post that network gets, not the canonical post.

What the validator checks

In this order, each failure a Violation (path: message):

Path Rule
kind the kind is one of kinds
media at least one media; at most mediaMaxCount
media[i].duration a video's duration within minDuration / maxDuration
media[i].size a video's size under maxVideoSize, a picture's under maxImageSize
media[i].format the mime, or the URL's extension, among videoFormats / imageFormats
media[i].ratio width / height within ratioTolerance of one of aspectRatios
media[i].width, media[i].height at least minWidth / minHeight
caption Post::text() - the caption with its hashtags - at most captionMaxLength characters (mb_strlen)
tags at most maxTags hashtags
title present when titleRequired; at most titleMaxLength characters
scheduledAt only where scheduling is offered

A rule is checked only when both the bound and the media's value are known: a duration, size, format (mime null), ratio or width the media does not carry is not checked. Media::image() and Media::video() leave mime and size null - build with new Media(...) to have them checked. The format check accepts the extension of the URL's path when the mime type is not listed: a video/x-unknown at .../clip.mp4 passes for Instagram.

What each network accepts

From each provider's capabilities():

Instagram YouTube
Kinds reel, image, carousel, story reel, video
Media up to 10 (a carousel's) 1
Video duration 3 s to 900 s - (a Short: 180 s, below)
Ratios 9:16, 4:5, 1:1, within 1 % any
Video size 1 GiB -
Image size 8 MiB -
Video formats video/mp4, video/quicktime, mp4, mov video/mp4, video/quicktime, video/webm, mp4, mov, webm
Image formats image/jpeg, jpg, jpeg -
Minimum width 320 px -
Caption 2200 characters 5000 (the description)
Hashtags 30 30
Title - required, 100 characters
Scheduling no yes

What the providers' validate() adds:

  • Instagram: a carousel has at least 2 media; any other kind exactly one; a reel is a video and is 9:16 (the validator lets 4:5 and 1:1 through for pictures); a picture post is a picture. Instagram's capabilities are one set for every kind, so the 3 s - 15 min and the size bounds apply to a carousel's or a story's video too, and a story is not held to 9:16.
  • YouTube: the media is a video; a REEL (a Short) lasts at most 180 seconds (YouTubeProvider::SHORT_MAX_DURATION). A longer vertical video is posted as a VIDEO.

Showing the violations

use Omnipost\Exception\InvalidPostException;
use Symfony\Component\Form\FormError;

try {
    $instagram->publish($post->for(Platform::INSTAGRAM));
} catch (InvalidPostException $e) {
    foreach ($e->violations as $violation) {
        $form->get(str_starts_with($violation->path, 'media') ? 'media' : $violation->path)->addError(new FormError($violation->message));
    }
}

InvalidPostException::$provider names the provider; its message is every violation, joined by ; ([youtube] title: A title is required.).

Propose a change docs/usage-validation.md

Reading an account

FeedInterface reads one's own account: who it is (account()) and what it posted (feed()), newest first, a page at a time - what a site caches to show a wall of its latest posts.

$account = $instagram->account();
printf("%s (%s) %s, %d followers, %d posts, %s\n", $account->username, $account->name, $account->url, $account->followers, $account->posts, $account->kind);

$feed = $instagram->feed(null, 2);
foreach ($feed->items as $item) {
    printf("  %s %-8s %s thumb=%s children=%d likes=%s\n", $item->publishedAt->format('Y-m-d'), $item->kind->value, $item->permalink, $item->thumbnailUrl, count($item->children), var_export($item->likes, true));
}
echo '  next: ', $feed->next, "\n";
$older = $instagram->feed($feed->next, 2);    // the next page

Run on 2026-10-26 on a MockHttpClient answering as Instagram documents it (a made-up account), with the mock's log of each request:

> GET https://graph.instagram.com/v23.0/me?fields=id,username,name,profile_picture_url,followers_count,media_count,account_type&access_token=***
claireviolin (Claire) https://www.instagram.com/claireviolin/, 4200 followers, 36 posts, MEDIA_CREATOR
> GET https://graph.instagram.com/v23.0/me/media?fields=id,media_type,media_product_type,media_url,thumbnail_url,permalink,caption,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}&limit=2&access_token=***
  2026-10-20 reel     https://www.instagram.com/reel/A/ thumb=https://cdn.example/1.jpg children=0 likes=120
  2026-10-10 carousel https://www.instagram.com/p/B/ thumb=https://cdn.example/21.jpg children=2 likes=NULL
  next: QVFI

Instagram

  • account(): GET /<user> (me by default) with the id, username, name, picture, followers, posts count and account type; url is https://www.instagram.com/<username>/.
  • feed($cursor, $limit): GET /<user>/media, limit between 1 and 100, after the cursor. next is the after cursor only when Instagram says there is a next page (paging.next): the last page has cursors too, and gives null.
  • Kinds: VIDEO is REEL when media_product_type is REELS (or missing), else VIDEO; CAROUSEL_ALBUM is CAROUSEL, with its children as items of their own; anything else is STORY when media_product_type is STORY, else IMAGE.
  • url is the media (media_url); thumbnailUrl is thumbnail_url, else for a picture the picture itself, for a carousel its first child's; likes and comments as Instagram gives them; duration and views are null.
  • Someone else's public post needs no API: Instagram's own embed (<blockquote class="instagram-media" data-instgrm-permalink="..."> and //www.instagram.com/embed.js).

YouTube

  • account(): the channel, GET /channels?part=snippet,statistics,contentDetails, by channel_id with the API key, or the token's own channel (mine=true) when no channel_id is set. The username is the channel's handle (customUrl, @claireviolin), else its title; url https://www.youtube.com/<handle> (or /channel/<id>); followers the subscribers, null when the channel hides them; posts the videos count. The channel is read once per provider object.
  • feed($cursor, $limit): the channel's uploads playlist (playlistItems, maxResults between 1 and 50, pageToken the cursor), then one videos?part=contentDetails,statistics call for the page's durations and counts. A video of 180 seconds at most is a REEL, a longer one a VIDEO (the API does not tell a Short, nor its ratio). permalink is the watch page, url the embed player (https://www.youtube.com/embed/<id>), caption the video's title, the thumbnail the largest given, publishedAt the video's publication time; likes, comments and views from the statistics.
  • A key does not see a private video (the provider's own note): read as the channel (no channel_id, the OAuth options set) to list them.

The harness's bare --recorded shows YouTube's account and feed on recorded answers (harness).

Caching

The networks limit the calls: read the feed from a scheduled job, store the items (raw keeps the network's own answer), and show the stored wall.

Propose a change docs/usage-reading.md

Tokens and connecting an account

The tokens stay with the site. A provider is built from the options it is given, connects an account through OAuth when asked, refreshes a token when asked, and hands back a Token for the site to store; it never stores anything itself.

Connecting an account (OAuth)

Both providers implement Auth\OAuthInterface. A back office sends the account's owner to authorizationUrl(), and on the way back exchanges the code:

// The "connect" button
$state = bin2hex(random_bytes(16));            // kept in the session, checked on the way back
return new RedirectResponse($instagram->authorizationUrl('https://site.example/connect/instagram', $state));

// The redirect URI
if (!hash_equals($session->get('state'), $request->query->get('state'))) { /* refuse */ }
$token = $instagram->exchange($request->query->get('code'), 'https://site.example/connect/instagram');
$store->save('instagram', $token->toArray());

The URLs, as the providers build them (run on 2026-10-26):

https://www.instagram.com/oauth/authorize?client_id=990001&redirect_uri=https%3A%2F%2Fsite.example%2Fconnect%2Finstagram&scope=instagram_business_basic%2Cinstagram_business_content_publish&response_type=code&state=csrf123
https://accounts.google.com/o/oauth2/v2/auth?client_id=cid.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fsite.example%2Fconnect%2Fyoutube&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.upload+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.readonly&access_type=offline&prompt=consent&state=csrf123
Instagram YouTube
Needs app_id (and app_secret to exchange) client_id (and client_secret to exchange)
Default scopes instagram_business_basic, instagram_business_content_publish (InstagramProvider::SCOPES) youtube.upload, youtube.readonly (YouTubeProvider::SCOPES)
Exchange the code (its trailing #_ removed) for a short-lived token at https://api.instagram.com/oauth/access_token, then that one for the long-lived token (GET /access_token?grant_type=ig_exchange_token) the code at https://oauth2.googleapis.com/token (grant_type=authorization_code)
Token answered the long-lived token, its end (60 days), the permissions granted as scopes, the account's id as accountId the access token (an hour), its end, the refresh token to keep, the scopes

The $scopes argument replaces the default scopes. The state is the caller's: generate it, keep it, and check it on the way back; the providers do not.

After exchange() the provider object calls with the new token from then on.

Refreshing

Both providers implement Auth\RefreshableInterface:

$token = $instagram->refresh();               // and the provider uses it from now on
$store->save('instagram', $token->toArray());
  • Instagram: the long-lived token lives 60 days. refresh() calls GET /refresh_access_token?grant_type=ig_refresh_token and gives 60 more days to a token at least 24 hours old and not dead yet; past its end, the account is connected again. Run it from a monthly job. token() is the token held, with no end known (expiresAt null) unless it came from refresh() or exchange().
  • YouTube: the access token lives an hour, drawn from the refresh token (client_id, client_secret, refresh_token options). The provider draws one before the first call that needs it and again within a minute of its end, by itself: refresh() is there to force it. token() is null until one was drawn. An access_token option given without a refresh token has no known end: it is used until Google refuses it (UnauthorizedException).

Run on 2026-10-26 on a mock answering as Instagram documents it:

> GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=***
refreshed: IGQ-new, until 2026-12-25, expiring within 10 days: no
["access_token","expires_at"]

Storing a token

Token::toArray() keeps what is known (access_token, expires_at in ATOM, refresh_token, scopes, account_id), Token::fromArray() reads it back. A provider is built with a stored token by passing it as an option over the configured ones:

$stored = Token::fromArray($store->load('instagram'));
$instagram = $registry->create('instagram', ['access_token' => $stored->accessToken]);

if ($stored->isExpiring(10)) {                 // within ten days of its end
    $store->save('instagram', $instagram->refresh()->toArray());
}

Registry::create() builds a provider afresh and does not keep it: get('instagram') is still the configured one. For YouTube, store the refresh token and pass it as refresh_token.

A dead token

A token that is expired, revoked or refused is an UnauthorizedException ($e->status, $e->providerCode): the account must be connected again. Instagram: HTTP 401, error code 190, or the subcodes 463 and 467. YouTube: HTTP 401, or the reasons invalid_grant (a revoked refresh token), invalid_client, unauthorized_client, authError, and a 403 insufficientPermissions (a token without the scope the call needs).

Propose a change docs/usage-tokens.md

Share links

Omnipost's second contract, beside publishing: share links for a page's visitors. A page's address, title, text, picture and hashtags turned into each network's own share page, an e-mail, a copied link and the device's share sheet. The links are plain links: no network's script is loaded, so no cookie is set and nothing needs the visitor's consent; there is no account, no credential and no API call. Publishing from an account is the other contract (PublisherInterface, Publishing).

use Omnipost\Share\Registry;
use Omnipost\Share\Shareable;

$share = Registry::defaults()->share(
    new Shareable('https://example.com/articles/fuji', 'Mount Fuji at dawn', image: 'https://example.com/fuji.jpg', hashtags: ['japan']),
    ['facebook', 'x', 'pinterest', 'email', 'native'],   // the ones the page shows, in its order
);
$share->url('x');     // https://twitter.com/intent/tweet?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji&text=Mount%20Fuji%20at%20dawn&hashtags=japan
$share->toArray();    // the one JSON Twig, Blade and React read

The share links need PHP 8.2 or later and nothing else: no framework, no template engine, no HTTP client - Omnipost\Share loads no class outside itself (Tests/Share/BareTest). A small script, assets/omnipost-share.js (no dependency), adds what a link cannot do: the device's share sheet, copying the address, asking the visitor's Mastodon instance.

They come from omnisense/omnishare, merged into this package on 2026-11-09 with its history: Omnishare\ is Omnipost\Share\ (its two exceptions Omnipost\Exception\), the label keys omnishare.* are omnipost.share.*, the script assets/omnishare.js is assets/omnipost-share.js (data-omnipost-share, OmnipostShare, omnipost-share:copied).

When to use it

  • Share buttons on articles, products or events, without embedding each network's widget (and its trackers): the buttons stay links, the consent banner has nothing to ask.
  • One set of links drawn the same way by a Symfony page, a Laravel twin and a React screen: they all read Share::toArray().

Not for posting to an account (PublisherInterface), counting shares (no network gives counts to plain links) or reading a page's Open Graph tags (the networks read them themselves).

Networks

All in this package, not in the omnipost/<network> providers: a share network is an address template, with no dependency, option or credential, so a provider package for each would be thirteen repositories of thirty lines each. A provider package is there for what needs an API (publishing, reading a feed, a token). An application, or a provider package, adds its own share network by implementing NetworkInterface (below).

Name Label key Icon Colour Takes Script action New tab
facebook omnipost.share.facebook facebook #1877f2 url - yes
x omnipost.share.x x #000000 url, title, text, hashtags, via - yes
pinterest omnipost.share.pinterest pinterest #e60023 url, image, title, text - yes
linkedin omnipost.share.linkedin linkedin #0a66c2 url - yes
bluesky omnipost.share.bluesky bluesky #1185fe url, title, text, hashtags - yes
mastodon omnipost.share.mastodon mastodon #6364ff url, title, text, hashtags instance when no instance is configured yes
threads omnipost.share.threads threads #000000 url, title, text, hashtags - yes
whatsapp omnipost.share.whatsapp whatsapp #25d366 url, title, text - yes
telegram omnipost.share.telegram telegram #26a5e4 url, title, text - yes
reddit omnipost.share.reddit reddit #ff4500 url, title - yes
email omnipost.share.email envelope - url, title, text - no
copy omnipost.share.copy link - url copy no
native omnipost.share.native share - url, title, text share no

"Takes" is what NetworkInterface::uses() declares; "New tab" is Link::newTab(), true for a share page on the web with no script action or with Mastodon's instance. The address each one builds is in The networks, with what each network was seen to do with it.

Instagram, YouTube and TikTok have no share page on the web - nothing a link can open with a page's address filled in - so they are not here: they are reached by publishing (the contracts per network).

Installation and use, in plain PHP

composer require omnisense/omnipost
<?php
require 'vendor/autoload.php';

use Omnipost\Share\Labels;
use Omnipost\Share\Registry;
use Omnipost\Share\Shareable;

$registry = Registry::defaults(mastodonInstance: null);   // null: the visitor gives their instance

$page = new Shareable(
    url: 'https://example.com/articles/fuji',         // whole: scheme and host
    title: 'Mount Fuji at dawn',
    text: null,                                      // posted instead of the title when given
    image: 'https://example.com/images/fuji.jpg',    // whole as well (Pinterest)
    hashtags: ['japan', 'travel'],
    via: 'example',                                  // X's "via @example"
);

$share = $registry->share($page, ['facebook', 'x', 'pinterest', 'email', 'copy', 'native']);

foreach ($share->links as $link) {
    printf(
        '<a href="%s"%s data-omnipost-share="%s">%s</a>'."\n",
        htmlspecialchars($link->url),
        $link->newTab() ? ' target="_blank" rel="noopener"' : '',
        htmlspecialchars((string) $link->action),
        htmlspecialchars(Labels::get($link->labelKey, 'en')),
    );
}
  • Shareable checks that its address and its picture are whole http(s) addresses (InvalidShareableException otherwise): a share page opens on another site, where a path means nothing.
  • Registry::share() takes the networks' names in the order the page shows them; null gives every network. An unknown name throws UnknownNetworkException.
  • Share::url('x'), Share::link('x'), Share::toArray() (also json_encode($share)).
  • Labels::get($key, $locale): the share links' own labels in English and French (Share/translations/<locale>.php, plain PHP arrays), for an application without its own words. An application with a translator imports those files into it, or keeps its own keys.

The Symfony bundle does not wire the share links: Registry::defaults() is a plain call, made where the links are drawn (a Twig function of the application's, a controller).

A network of one's own

use Omnipost\Share\Network\AbstractNetwork;
use Omnipost\Share\Shareable;

final class Hatena extends AbstractNetwork
{
    public function name(): string { return 'hatena'; }
    public function uses(): array { return ['url', 'title']; }
    public function shareUrl(Shareable $shareable): string
    {
        return 'https://b.hatena.ne.jp/entry/panel/?'.self::query(['url' => $shareable->url, 'title' => $shareable->title]);
    }
}

$registry->add(new Hatena());   // a network of the same name is replaced

Its label key is omnipost.share.hatena (AbstractNetwork::labelKey()): an application gives it its words, or overrides labelKey().

Propose a change docs/share.md

Share links, concepts

Shareable

Omnipost\Share\Shareable (final, readonly) is what is shared: a page, by its canonical address, with what the networks show of it.

Property Type Default
url string - the page's canonical address, whole (http or https scheme and a host)
title string - the page's title: the e-mail's subject, Reddit's title, the message when there is no text
text ?string null a sentence posted with the link instead of the title
image ?string null the page's share picture, whole (Pinterest's pin); '' is accepted as none
hashtags list<string> [] each trimmed, its leading # removed, the empty ones dropped
via ?string null an account to credit, without its @ (X's via)

The constructor throws InvalidShareableException when url, or a non-empty image, has no http/https scheme or no host: a share page opens on another site, where a path means nothing.

Method
message(): string the text when there is one (not null, not ''), else the title
toArray(): array url, title, text, image, hashtags, via

Registry

Omnipost\Share\Registry holds the networks by name. It is not Omnipost\Registry, which builds the configured providers from their factories: a share network has no option, no credential and no factory.

Method
new Registry(iterable $networks = []) a registry of those networks
Registry::defaults(?string $mastodonInstance = null) the 13 networks, in the order of the table; Mastodon's link goes to $mastodonInstance, or is asked of the visitor when null
add(NetworkInterface $network): self adds a network, or replaces the one of the same name
has(string $name): bool
get(string $name): NetworkInterface UnknownNetworkException for a name it does not hold, listing the known ones
names(): list<string> in the order they were added
share(Shareable $shareable, ?array $networks = null): Share the links to those networks, in the order given; null: every network

Omnipost\Share\Share (final, readonly, JsonSerializable) is a Shareable and its links, keyed by network name in the order asked for: what a template draws.

Member
shareable the Shareable
links array<string, Link>
link(string $network): ?Link null when it was not asked for
url(string $network): ?string that link's address
toArray(), jsonSerialize() the Shareable's array plus links: each link's array

Omnipost\Share\Link (final, readonly) is one network's link for one Shareable:

Property or method Type
network string the network's name
url string the share page, a mailto:, or the page's own address (copy, share sheet)
labelKey string omnipost.share.<name>, a key of the labels below; label in the array
icon string a name for the application's icon set
color ?string the brand colour, #rrggbb, null without a brand
action ?string what the script does on a click: share, copy, instance; null for a plain link
newTab(): bool true when the address is http(s) and there is no action, or the action is instance (Mastodon's share page, the script's markup has target="_blank"): open it in a new tab, rel="noopener"; false for a mailto:, share and copy
Link::of(NetworkInterface, Shareable) builds one
toArray() network, url, label, icon, color, newTab, action

NetworkInterface and AbstractNetwork

Omnipost\Share\NetworkInterface is a place a page can be shared to. A network builds an address from a Shareable and says how it is shown; it calls nothing and holds no credential.

Method
name(): string its name in a registry and in the JSON
labelKey(): string the key of its label
icon(): string an icon's name
color(): ?string its brand colour, or null
uses(): list<'url'|'title'|'text'|'image'|'hashtags'|'via'> what it takes of a Shareable (informative: the address is what shareUrl() builds)
shareUrl(Shareable): string its address for this Shareable
action(): ?string the script's action, or null

Omnipost\Share\Network\AbstractNetwork implements all but name(), uses() and shareUrl(): label key omnipost.share.<name>, icon <name>, no colour, no action. Two helpers for subclasses:

  • query(array $parameters): string - a query string encoded as RFC 3986 says (rawurlencode: a space is %20, never +, which a mail client would show as it is), in the order given, the null and empty values left out;
  • post(Shareable $shareable, bool $hashtags = true): string - the message, the hashtags as #tag, and the address, joined by spaces: for the networks that take a single text.

Omnipost\Share\Network\Mastodon takes the instance in its constructor: a host (piaille.fr) or an address whose host is taken (https://Piaille.fr/@someone gives piaille.fr); instance() answers it, null when none was given (the link then goes to Mastodon::FALLBACK, mastodon.social, and the script asks the visitor).

Labels

Omnipost\Share\Labels holds the share links' own labels, in English and French (Share/translations/en.php, Share/translations/fr.php, plain PHP arrays), for an application without words of its own.

Method
Labels::locales() ['en', 'fr']
Labels::catalogue(string $locale) the labels of a locale (fr, fr_FR, fr-CA: the first two letters), English for any other
Labels::get(string $key, string $locale = 'en') a label, the key itself when there is none

The keys: omnipost.share.title (the list's name, "Share this page"), one per network (omnipost.share.facebook...), omnipost.share.mastodon.instance (the question the script asks), omnipost.share.copied (to say the address was copied). An application with a translator imports the two files into its catalogues, or keeps its own keys.

Propose a change docs/share-concepts.md

Share links, the networks

The addresses built

For new Shareable('https://example.com/articles/fuji', 'Mount Fuji at dawn', 'The light at 5 am', 'https://example.com/fuji.jpg', ['#japan', ' travel ', ''], 'example') - the hashtags trimmed, their # and the empty one dropped - Registry::defaults() builds (run in the harness on 2026-10-26):

Network Address
facebook https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fexample.com%2Farticles%2Ffuji
x https://twitter.com/intent/tweet?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji&text=The%20light%20at%205%20am&hashtags=japan%2Ctravel&via=example
pinterest https://pinterest.com/pin/create/button/?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji&media=https%3A%2F%2Fexample.com%2Ffuji.jpg&description=The%20light%20at%205%20am
linkedin https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji
bluesky https://bsky.app/intent/compose?text=The%20light%20at%205%20am%20%23japan%20%23travel%20https%3A%2F%2Fexample.com%2Farticles%2Ffuji
mastodon https://mastodon.social/share?text=The%20light%20at%205%20am%20%23japan%20%23travel%20https%3A%2F%2Fexample.com%2Farticles%2Ffuji
threads https://www.threads.com/intent/post?text=The%20light%20at%205%20am%20%23japan%20%23travel&url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji
whatsapp https://wa.me/?text=The%20light%20at%205%20am%20https%3A%2F%2Fexample.com%2Farticles%2Ffuji
telegram https://t.me/share/url?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji&text=The%20light%20at%205%20am
reddit https://www.reddit.com/submit?url=https%3A%2F%2Fexample.com%2Farticles%2Ffuji&title=Mount%20Fuji%20at%20dawn
email mailto:?subject=Mount%20Fuji%20at%20dawn&body=The%20light%20at%205%20am%0A%0Ahttps%3A%2F%2Fexample.com%2Farticles%2Ffuji
copy https://example.com/articles/fuji
native https://example.com/articles/fuji

The message is the text when there is one, else the title (Reddit and the e-mail's subject always take the title). Bluesky, Mastodon and WhatsApp take one text: the message, then the hashtags as #tag (not WhatsApp), then the address; Threads takes the message and hashtags as text and the address apart. Parameters left empty or null are not written. Mastodon goes to the instance given to Registry::defaults($mastodonInstance), else to mastodon.social with the script's instance action.

What each network was seen to do

Each address below is what the core builds for one page (https://example.com/articles/fuji?lang=fr, "Le Fuji (l'ascension) & nous !", a picture, the hashtags japon and fuji, the account lesringostars). Each was opened on 2026-10-23 in a headless Chrome, signed out, to see what the network's page receives.

Network Address Seen, signed out
Facebook https://www.facebook.com/sharer/sharer.php?u=… "You are not logged in"; the address kept in the page's own address (u). Facebook takes the title and picture from the page's og:* tags. Verified as far as an anonymous visit goes
X https://twitter.com/intent/tweet?url=…&text=…&hashtags=japon%2Cfuji&via=… twitter.com sends to x.com's sign-in, whose redirect_after_login carries url, text, hashtags and via intact. Verified
Pinterest https://pinterest.com/pin/create/button/?url=…&media=…&description=… www.pinterest.com's "Pin Builder" sign-in, the three parameters kept. Verified
LinkedIn https://www.linkedin.com/sharing/share-offsite/?url=… the sign-in, whose session_redirect carries the address to shareArticle. LinkedIn no longer takes a title or summary in the address. Verified
Bluesky https://bsky.app/intent/compose?text=… the app recognises the intent and, signed out, leaves it for its home page (nothing stored). Verified in Bluesky's source (below): signed in, the composer opens with text; signed out, the intent is dropped and the text is lost - not kept for after the sign-in
Mastodon https://<instance>/share?text=… mastodon.social and piaille.fr answer 302 to their sign-in (a path that does not exist answers 404): the share page is there, behind the sign-in. Verified in Mastodon's source (below): the share page is the composer, its text title, text and url joined by spaces
Threads https://www.threads.com/intent/post?text=…&url=… the sign-in, whose next carries text (with the hashtags) and url intact (threads.net answers 301 to threads.com). Verified
WhatsApp https://wa.me/?text=… api.whatsapp.com's "Share on WhatsApp" shows "Le Fuji (l'ascension) & nous ! https://example.com/articles/fuji?lang=fr". Verified
Telegram https://t.me/share/url?url=…&text=… "Telegram: Share Web Page" shows the address and the title. Verified
Reddit https://www.reddit.com/submit?url=…&title=… a "prove your humanity" challenge for a headless browser; the address and the title kept. Verified to that point
E-mail mailto:?subject=…&body=… no network: the subject and body decode to the title and the address (a space is %20)
Copy the address assets/omnipost-share.js in a headless Chrome: the address on the clipboard
Share sheet the address assets/omnipost-share.js in a headless Chrome: navigator.share given the title and the address

Mastodon and Bluesky, in their source (2026-10-24)

Their composer cannot be seen without an account; what they do with the address was read in their own code instead:

  • Mastodon (mastodon/mastodon, branch main at f045f6b43): config/routes.rb has resource :share, only: [:show]; SharesController runs before_action :authenticate_user! (the 302 to the sign-in seen live) and renders shares/show, which mounts the standalone composer (entrypoints/share.tsx). ApplicationHelper#render_initial_state gives it text: [params[:title], params[:text], params[:url]].compact.join(' ') (InitialStateSerializer: store[:text]), and the compose reducer, hydrated, sets the composer's text and focuses it. So the one text built here (message, hashtags, address) is the post as it is written; visibility (public, unlisted, private, direct) is read too, and not given here. Signed out, Devise's sign-in returns to the address asked by its default (store_location for a GET page): read, not seen.
  • Bluesky (bluesky-social/social-app, branch main at 4e4ad9c5f): src/lib/hooks/useIntentHandler.ts reads /intent/compose and its text (also imageUris and videoUri, local files of the native app only: an http(s) address is filtered out, and the web app never takes images). useComposeIntent begins with if (!hasSession) return: a visitor signed in at bsky.app gets the composer with the text; one signed out keeps nothing - the home page seen live - and must share again once signed in. Bluesky draws the link's card from the address in the text.

Addresses that were not chosen: X's newer x.com/intent/post (twitter.com's intent answers 301 to x.com and keeps every parameter; lesringostars' links used twitter.com before these share links and are unchanged); Facebook's quote (ignored by Facebook since 2023); LinkedIn's shareArticle?mini=true with title and summary (LinkedIn sends share-offsite there itself and drops them).

Propose a change docs/share-networks.md

Share links in templates

Share::toArray() is the same array whatever draws it:

{
  "url": "https://example.com/articles/fuji", "title": "Mount Fuji at dawn", "text": null,
  "image": "https://example.com/images/fuji.jpg", "hashtags": ["japan"], "via": null,
  "links": {
    "facebook": {"network": "facebook", "url": "https://www.facebook.com/sharer/sharer.php?u=…", "label": "omnipost.share.facebook",
                 "icon": "facebook", "color": "#1877f2", "newTab": true, "action": null},
    "email":    {"network": "email", "url": "mailto:?subject=…&body=…", "label": "omnipost.share.email",
                 "icon": "envelope", "color": null, "newTab": false, "action": null},
    "native":   {"network": "native", "url": "https://example.com/articles/fuji", "label": "omnipost.share.native",
                 "icon": "share", "color": null, "newTab": false, "action": "share"}
  }
}

icon is a name for the application's own icon set (Font Awesome: fa-brands fa-<icon>, with fa-solid for envelope, link and share - share-nodes in Font Awesome 6). label is a key of the share labels' catalogues (Share/translations/); an application may use its own words instead.

Twig

<ul class="share" aria-label="{{ 'omnipost.share.title'|trans }}">
  {% for link in share.links %}
    <li{% if link.action in ['share', 'copy'] %} data-omnipost-share-item hidden{% endif %}>
      <a href="{{ link.url }}"{% if link.newTab %} target="_blank" rel="noopener"{% endif %}
         {% if link.action %}data-omnipost-share="{{ link.action }}" data-omnipost-share-title="{{ share.title }}"{% endif %}
         aria-label="{{ link.label|trans }}" style="--share-color: {{ link.color ?? 'currentColor' }}">
        <i class="fa-{{ link.icon in ['envelope', 'link', 'share'] ? 'solid' : 'brands' }} fa-{{ link.icon }}" aria-hidden="true"></i>
      </a>
    </li>
  {% endfor %}
</ul>

share is $share->toArray() given to the template (a Twig function of the application's, or a controller's variable).

Blade

The same loop in Blade, $share the array:

<ul class="share" aria-label="{{ __('omnipost.share.title') }}">
  @foreach ($share['links'] as $link)
    <li @if (in_array($link['action'], ['share', 'copy'])) data-omnipost-share-item hidden @endif>
      <a href="{{ $link['url'] }}" @if ($link['newTab']) target="_blank" rel="noopener" @endif
         @if ($link['action']) data-omnipost-share="{{ $link['action'] }}" data-omnipost-share-title="{{ $share['title'] }}" @endif
         aria-label="{{ __($link['label']) }}">…</a>
    </li>
  @endforeach
</ul>

React

The screen's props carry the same array (share: ShareJson); the share sheet and the copy are the component's own state rather than the script's:

type Link = { network: string; url: string; label: string; icon: string; color: string | null; newTab: boolean; action: string | null };
type ShareJson = { url: string; title: string; text: string | null; image: string | null; hashtags: string[]; via: string | null; links: Record<string, Link> };

function Share({ share, t }: { share: ShareJson; t: (key: string) => string }) {
  const [native, setNative] = useState(false);
  useEffect(() => setNative(typeof navigator.share === 'function'), []);
  return (
    <ul className="share">
      {Object.values(share.links).map((link) => link.action === 'share'
        ? <li key={link.network} hidden={!native}><button type="button" onClick={() => navigator.share({ title: share.title, url: share.url }).catch(() => {})} aria-label={t(link.label)} /></li>
        : <li key={link.network}><a href={link.url} {...(link.newTab ? { target: '_blank', rel: 'noopener' } : {})} aria-label={t(link.label)} /></li>)}
    </ul>
  );
}

Propose a change docs/share-templates.md

The share script

assets/omnipost-share.js (no dependency, ES5, 6 KB with its comments) does what a plain link cannot, only where the browser can:

data-omnipost-share On a click Shown
share navigator.share({title, text, url}) from data-omnipost-share-title, data-omnipost-share-text and data-omnipost-share-url (the href by default) only where navigator.share exists
copy the address on the clipboard; data-omnipost-share-copied on the link for two seconds and an omnipost-share:copied event (bubbling, detail.url) for the page to say so only where navigator.clipboard.writeText exists
instance asks the visitor's Mastodon instance (prompt(), with data-omnipost-share-prompt as the question and the link's instance as the answer offered), then opens the same share page there always (without the script, the link goes to its own instance)

Mark the hidden ones hidden; the attribute may sit on an ancestor marked data-omnipost-share-item (a list item) instead of the link. Nothing is stored: not the instance, not anything else.

<script src="/vendor/omnipost/omnipost-share.js" defer></script>

Loaded as a script it starts itself: one click listener on the document (so links added later work), and the reveal at DOMContentLoaded and at every load - libraries that swap pages in dispatch load again. OmnipostShare.reveal(element) reveals what is under an element added since. Required from CommonJS (a bundler, from vendor/omnisense/omnipost/assets/omnipost-share.js) it only exports reveal, handle, instance, onInstance, available and start.

Propose a change docs/share-javascript.md

Configuration reference

The registry

new Registry(iterable $factories, array $config);
Key Type Required
<name> string - the provider's name: get('<name>'), and in Symfony the argument's name
<name>.factory string yes a factory's getName(): instagram, youtube, or an application's own
<name>.options map no, default [] handed to the factory's create(); create($name, $overrides) puts other values over them

The same tree is the Symfony bundle's omnipost.providers (Symfony). Two accounts on the same network are two names on the same factory:

new Registry([new InstagramProviderFactory($http)], [
    'instagram_artist' => ['factory' => 'instagram', 'options' => ['access_token' => $artistToken]],
    'instagram_label' => ['factory' => 'instagram', 'options' => ['access_token' => $labelToken]],
]);

An option that is null or '' is as if not given (ProviderFactory::createConfig()): the default stands. No option of either provider is required to build it; a call that needs a missing credential throws InvalidConfigException naming what it needs.

Instagram (instagram)

From Omnipost\Instagram\InstagramProviderFactory:

Option Type Default Needed for
access_token string none every call but authorizationUrl(), exchange() the long-lived token (60 days)
app_id string none authorizationUrl(), exchange() the Instagram app's id
app_secret string none exchange() the Instagram app's secret
user_id string me - the account the calls are made for: <user_id>/media, <user_id>/media_publish
share_to_feed bool true - a reel also shown on the profile's grid; read with FILTER_VALIDATE_BOOL ('false', '0', 'off' are false). A post's share_to_feed option overrides it
api_version string v23.0 (Api::VERSION) - the Graph API version in the path; '' is the default too (empty is dropped)
base_uri string https://graph.instagram.com (Api::BASE_URI) - the API's root

Post options it reads (Post::$options, or a variant's): share_to_feed (bool), thumb_offset (milliseconds into the reel for its cover, sent as given).

YouTube (youtube)

From Omnipost\YouTube\YouTubeProviderFactory:

Option Type Default Needed for
api_key string none reading another channel, or reading without OAuth sent as X-Goog-Api-Key
channel_id string none - the channel read (UC...); without it, the channel the OAuth token belongs to (mine=true)
client_id string none uploading, deleting, authorizationUrl(), exchange(), refresh() the Google OAuth client
client_secret string none the same but authorizationUrl()
refresh_token string none uploading, deleting, refresh() (with the two above) what exchange() gave: access tokens are drawn from it
access_token string none an alternative to the three above a token at hand (an hour's life), used until refused
privacy string private - what an upload is set to: private, unlisted, public. A post's privacy option overrides it
category_id string none - the video category (10 is Music); YouTube's default otherwise. A post's category_id option overrides it
base_uri string https://www.googleapis.com/youtube/v3 - the Data API's root
upload_uri string https://www.googleapis.com/upload/youtube/v3 - the upload root

Which credential a call uses:

Call With channel_id Without channel_id
account(), feed() the key; without one, the OAuth token the OAuth token (mine=true); without one, InvalidConfigException
status() the OAuth token when it can, else the key the same
publish(), delete() the OAuth token, required the same

Post options it reads: privacy, category_id.

The token endpoint (https://oauth2.googleapis.com/token) and the consent page (https://accounts.google.com/o/oauth2/v2/auth) are constants, not options.

Environment variables

The packages read no environment variable themselves. The names used in these pages, in the bundle's docblock and in the harness are conventions: INSTAGRAM_ACCESS_TOKEN, INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, YOUTUBE_API_KEY, YOUTUBE_CHANNEL_ID, YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, YOUTUBE_REFRESH_TOKEN; OMNIPOST_PLUGINS for the harness.

Propose a change docs/configuration.md

Errors

Every exception implements Omnipost\Exception\OmnipostException (an interface that extends \Throwable).

Exception Parent Thrown when
InvalidConfigException \LogicException the registry is asked for a name not configured, or one whose factory was not given; a call needs a credential the provider was not given
InvalidPostException \InvalidArgumentException publish() was given a post the network would refuse: nothing was sent
NotSupportedException \LogicException the provider does not do that (Instagram's delete(), a kind it cannot publish)
ProviderException \RuntimeException the network refused the call, could not be reached, or answered without what was expected
UnauthorizedException ProviderException the token is dead or refused: the account must be connected again

Properties and messages

Exception Properties Message
InvalidPostException provider (string), violations (list<Violation>) [<provider>] <path>: <message>; <path>: <message>
ProviderException, UnauthorizedException provider (string), status (?int, the HTTP status; null when unreachable), providerCode (?string, the network's code) [<provider>] <the network's message>
InvalidConfigException - The "youtube" provider needs: client_id, client_secret, refresh_token (or an access_token) to upload.
NotSupportedException - The "instagram" provider does not delete posts (the API cannot: the account does, in the application).

There is no "unavailable" or "rate limited" class in this family: a network that cannot be reached, or asks to slow down, is a ProviderException too.

No secret in a failure

A provider keeps its credentials out of what it throws: the HTTP client's message with a key or a token masked (access_token=***, Http\Redaction::text()), the previous exception a copy of the client's (Redaction::copy(): the same class and code), and no trace of the chain keeping its arguments, where the options, headers and token stood (zend.exception_ignore_args off).

By provider

Run on 2026-10-26 on mocked answers (the networks' documented error shapes):

youtube: ProviderException status=403 code='insufficientPermissions' | [youtube] Request had insufficient authentication scopes.
youtube: ProviderException status=403 code='quotaExceeded' | [youtube] The request cannot be completed because you have exceeded your quota.
youtube: UnauthorizedException status=400 code='invalid_grant' | [youtube] Token has been expired or revoked.
youtube: ProviderException status=NULL code=NULL | [youtube] YouTube could not be reached: Could not resolve host
instagram: UnauthorizedException status=400 code='190' | [instagram] Error validating access token
instagram: ProviderException status=400 code='4' | [instagram] Too many calls, try again later: Application request limit reached
instagram: ProviderException status=NULL code=NULL | [instagram] Instagram could not be reached: timeout for https://graph.instagram.com/v23.0/me?access_token=***
InvalidConfigException: The "youtube" provider needs: api_key and channel_id to read a channel (or client_id, client_secret, refresh_token to read its own).
InvalidConfigException: The "instagram" provider needs: access_token.
InvalidConfigException: The "youtube" provider needs: client_id, client_secret, refresh_token (or an access_token) to upload.

Instagram (omnipost/instagram, Api::error()): an answer of 400 or more, or one carrying error / error_type, is an exception. HTTP 401, Graph code 190, or subcode 463 or 467 is UnauthorizedException; codes 4, 17, 32 and 613 (Meta's call limits, Api::RATE_LIMIT_CODES) a ProviderException whose message starts Too many calls, try again later:; anything else a ProviderException with Graph's code as providerCode. A transport error is a ProviderException not chained to the client's exception, and every access_token=, client_secret= and code= in a message is masked (***): the URL in a transport message carries the token.

YouTube (omnipost/youtube, Api::decode()): an answer of 400 or more, or one carrying error, is an exception. The reason is the API's first errors[].reason, or the token endpoint's error. HTTP 401, or the reasons invalid_grant, invalid_client, unauthorized_client, authError, is UnauthorizedException; anything else a ProviderException with the reason as providerCode (quotaExceeded, insufficientPermissions...). A transport error is a ProviderException chained to the client's exception. Other cases: no upload URL in the answer, the video's file unreadable, the video's URL answering other than 200, a status asked for a video that does not exist (404, videoNotFound), a channel not found (404, channelNotFound), no uploads playlist, no token in Google's answer.

YouTube's 403 insufficientPermissions (a token without the scope the call needs) is an UnauthorizedException: the account is connected again with the scope.

What to catch

use Omnipost\Exception\InvalidConfigException;
use Omnipost\Exception\InvalidPostException;
use Omnipost\Exception\NotSupportedException;
use Omnipost\Exception\ProviderException;
use Omnipost\Exception\UnauthorizedException;

try {
    $publication = $provider->publish($post->for($provider->getPlatform()));
} catch (InvalidPostException $e) {
    // show $e->violations; nothing was sent
} catch (UnauthorizedException) {
    // ask the account's owner to connect it again
} catch (InvalidConfigException) {
    // this provider is declared, not configured (no token yet)
} catch (NotSupportedException) {
    // this network does not do that
} catch (ProviderException $e) {
    // the network refused, or could not be reached: $e->status, $e->providerCode; retry later
}

The share links throw two, both OmnipostExceptions too, both programming errors to fix rather than conditions to handle:

Exception Parent When Message
InvalidShareableException \InvalidArgumentException a Share\Shareable's url, or its non-empty image, is not a whole http(s) address The url to share must be a whole http(s) address, "/articles/fuji" given.
UnknownNetworkException \OutOfBoundsException Share\Registry::get() or share() asked for a name the registry does not hold No share network "myspace"; known: facebook, x, pinterest, ...

No share network calls anything, so nothing fails at run time once the links are built. A relative address is the usual cause of the first: build the Shareable from the page's absolute URL (Symfony's url(), Laravel's url() or route(), not path()).

Propose a change docs/errors.md

Testing

An application that uses Omnipost tests its own code, not the networks: the providers take the HTTP client they call with, so a test hands them a MockHttpClient and the answers it wants, and checks what was sent. The validator calls nothing and needs no double.

Omnipost ships no fake provider and no conformance suite: there is no Testing\ namespace in the package. The core's own stub (Omnipost\Tests\StubFactory) is in its Tests/ directory, for its development only.

A test case

use Omnipost\Exception\InvalidPostException;
use Omnipost\Instagram\InstagramProviderFactory;
use Omnipost\Model\Media;
use Omnipost\Model\Post;
use Omnipost\Model\PublicationState;
use Omnipost\Platform;
use Omnipost\Registry;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class PublishingTest extends TestCase
{
    public function testAReelGoesToInstagramAsAContainer(): void
    {
        $container = new MockResponse('{"id":"17900000000000001"}');
        $registry = new Registry([new InstagramProviderFactory(new MockHttpClient([$container]))], [
            'instagram' => ['factory' => 'instagram', 'options' => ['access_token' => 'test']],
        ]);

        $reel = Post::reel(Media::video('https://site.example/reel.mp4', null, 42.0, 1080, 1920), 'Tonight', null, ['violin']);
        $publication = $registry->get('instagram')->publish($reel->for(Platform::INSTAGRAM));

        self::assertSame(PublicationState::PENDING, $publication->state);
        self::assertSame('17900000000000001', $publication->id);
        self::assertSame('POST', $container->getRequestMethod());
        self::assertSame('https://graph.instagram.com/v23.0/me/media', $container->getRequestUrl());
        parse_str($container->getRequestOptions()['body'], $sent);
        self::assertSame(['media_type' => 'REELS', 'video_url' => 'https://site.example/reel.mp4', 'caption' => "Tonight\n\n#violin", 'share_to_feed' => 'true', 'access_token' => 'test'], $sent);
    }

    public function testALandscapeReelIsRefusedBeforeAnyRequest(): void
    {
        $http = new MockHttpClient([]);   // no answer: any request would fail the test
        $instagram = (new InstagramProviderFactory($http))->create(['access_token' => 'test']);

        try {
            $instagram->publish(Post::reel(Media::video('https://site.example/clip.mp4', null, 42.0, 1920, 1080)));
            self::fail('refused');
        } catch (InvalidPostException $e) {
            self::assertSame(['media[0].ratio'], array_map(static fn ($v) => $v->path, $e->violations));
        }
        self::assertSame(0, $http->getRequestsCount());
    }
}
$ vendor/bin/phpunit --bootstrap vendor/autoload.php PublishingTest.php
PHPUnit 11.5.57 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.27

..                                                                  2 / 2 (100%)

Time: 00:00.259, Memory: 8.00 MB

OK (2 tests, 7 assertions)

(run on 2026-10-26 on the packages' checkouts, no network)

  • MockResponse::getRequestMethod(), getRequestUrl() and getRequestOptions() give what was sent: the container's fields for Instagram (a form body), the metadata for YouTube (a JSON body).
  • A MockHttpClient built with an empty list fails on any request: a test that a refused post sends nothing needs no assertion of its own beyond getRequestsCount().
  • A multi-step flow (Instagram's container, then its status, then media_publish) takes a list of answers in order, or a callable that answers by URL, as the examples of Publishing do.

Errors as answers

The networks' documented error shapes, to give a mock:

To test Answer
Instagram, a dead token new MockResponse('{"error":{"message":"Error validating access token","type":"OAuthException","code":190}}', ['http_code' => 400])
Instagram, too many calls the same with "code":4
YouTube, a revoked refresh token new MockResponse('{"error":"invalid_grant","error_description":"Token has been expired or revoked."}', ['http_code' => 400]) on the token endpoint
YouTube, the quota new MockResponse('{"error":{"code":403,"message":"...","errors":[{"reason":"quotaExceeded"}]}}', ['http_code' => 403])
unreachable new MockResponse('', ['error' => 'Could not resolve host'])

What each becomes: Errors.

A provider of the test's own

To test code that takes a PublisherInterface without any network, implement it:

use Omnipost\Model\Capabilities;
use Omnipost\Model\Post;
use Omnipost\Model\PostKind;
use Omnipost\Model\Publication;
use Omnipost\Model\PublicationState;
use Omnipost\Platform;
use Omnipost\PublisherInterface;

final class RecordingPublisher implements PublisherInterface
{
    /** @var list<Post> */
    public array $published = [];

    public function getName(): string { return 'recording'; }
    public function getPlatform(): Platform { return Platform::MASTODON; }
    public function capabilities(): Capabilities { return new Capabilities([PostKind::REEL, PostKind::IMAGE]); }
    public function publish(Post $post): Publication
    {
        $this->published[] = $post;

        return new Publication(Platform::MASTODON, 'p'.count($this->published), PublicationState::PUBLISHED);
    }
    public function status(string $publicationId): Publication { return new Publication(Platform::MASTODON, $publicationId, PublicationState::PUBLISHED); }
    public function delete(string $publicationId): void {}
}

In Symfony, replace http_client with a MockHttpClient in the test environment (framework.http_client.mock_response_factory), or a provider's service (omnipost.provider.<name>) with static::getContainer()->set(...) before it is first used.

Share links are strings built from a Shareable: test them as strings, with no browser and no network.

use Omnipost\Share\Registry;
use Omnipost\Share\Shareable;
use PHPUnit\Framework\TestCase;

final class ArticleShareTest extends TestCase
{
    public function testTheArticleIsSharedWithItsPicture(): void
    {
        $share = Registry::defaults()->share(
            new Shareable('https://example.com/articles/fuji', 'Mount Fuji at dawn', image: 'https://example.com/fuji.jpg'),
            ['pinterest', 'email'],
        );

        self::assertSame(['pinterest', 'email'], array_keys($share->links));
        self::assertStringContainsString('media=https%3A%2F%2Fexample.com%2Ffuji.jpg', $share->url('pinterest'));
        self::assertStringStartsWith('mailto:?subject=Mount%20Fuji%20at%20dawn', $share->url('email'));
        self::assertFalse($share->link('email')->newTab());
    }
}

In a functional test of a page, assert the rendered hrefs (Symfony's WebTestCase crawler, Laravel's assertSee()): the addresses are deterministic for a given page.

The package's own tests

docker compose run --rm omnipost test runs every package's tests in the harness: the core's (the registry, capabilities and ratios, posts and variants, tokens, the validator rule by rule, the bundle, the plain-PHP run with no framework class loaded) and each provider's on answers written in the shapes the networks document. The share links' are in Tests/Share/: NetworksTest (every network's address for one page, with and without text, picture, hashtags and account, the RFC 3986 encoding, Mastodon's instance), ShareTest (the order asked, the JSON, the defaults, a network added or replaced, an unknown network, an address that is not whole, the labels), BareTest (plain PHP with Composer's autoloader alone, nothing loaded but Omnipost\Share); docker compose run --rm js runs Tests/js/share.test.cjs, the script's, on node's test runner. PHPStan checks the share links at its highest level (phpstan.neon).

Propose a change docs/testing.md

The Docker harness

docker/ runs this package with every omnipost/* provider installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNIPOST_PLUGINS=../.. is set in docker/.env - and a console that exercises them with the tokens in docker/.env.

cd docker && cp .env.dist .env       # your accounts' tokens, when you have them
docker compose run --rm omnipost providers
Command
providers the providers installed, and what each can do with the variables in .env
capabilities <provider> what the network accepts (no token needed)
validate <provider> <media> [caption] the violations a post would meet: nothing is sent
account <provider> the connected account
feed <provider> the account's posts, newest first (--limit, --cursor)
publish <provider> <media> [caption] a post published, then followed until it is, or fails (--wait)
status <provider> <id> where a publication stands
refresh <provider> a fresh token, printed as a site would store it
authorize <provider> <redirect-uri>, exchange <provider> <code> <redirect-uri> connecting the account
bare plain PHP: the registry built by hand, two posts checked, YouTube asked, what PHP loaded
bare-share plain PHP: every share link of a page, as JSON, and every class PHP loaded for it
test every package's tests

docker compose run --rm js runs the share script's tests (Tests/js/share.test.cjs) on node's test runner, in a node:22-alpine container.

validate and publish take --title, --kind, --tag, --file, --cover, --duration, --width, --height and -o option=value; they measure what they can (the type and size the server announces, a local --file).

docker compose run --rm omnipost capabilities instagram
docker compose run --rm omnipost validate instagram https://site.example/reel.mp4 "Strauss" --duration 42 --width 1080 --height 1920 --tag violin
docker compose run --rm omnipost feed youtube --limit 5
docker compose run --rm omnipost publish youtube https://site.example/reel.mp4 "Strauss" --title "Strauss, Sonata" -o privacy=unlisted

Bare: no bundle, no container

The console above is a symfony/console application over a registry built by hand; bare is less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing else. It builds the Registry from the provider packages installed, asks each provider what it does, checks two posts against what each network accepts (nothing is sent), asks YouTube for the channel and its two latest videos - the real Data API when YOUTUBE_API_KEY and YOUTUBE_CHANNEL_ID are set, or with --recorded the answers kept in docker/harness/recorded/ - then lists what PHP loaded and exits 1 if a class of a framework is among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig):

$ docker compose run --rm omnipost bare --recorded
Omnipost in bare PHP: the registry built by hand, no bundle, no container.

  instagram  publishes, has a feed
  youtube    publishes, has a feed

Checked before anything is sent, a vertical reel of 42 seconds:
  instagram  accepted
  youtube    accepted

Checked before anything is sent, a landscape reel of twelve minutes:
  instagram  media[0].ratio: 16:9 is not accepted (accepted: 9:16, 4:5, 1:1).
  youtube    title: A title is required.

omnipost/youtube, from the answers kept in recorded/:
  Claire (@claireviolin), https://www.youtube.com/@claireviolin - 4200 subscribers, 36 videos
  2026-09-30  reel  Strauss, Sonata, 00:58, 1200 views
              https://www.youtube.com/watch?v=shortA
  2026-09-01  video The whole concert, 1:02:03, 50 views
              https://www.youtube.com/watch?v=longB

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, a bridge, Doctrine, Twig): none

The answers in recorded/ were not taken from YouTube: they are written in the shape the Data API v3 documents, as in omnipost/youtube's tests - the channel and its videos are made up. bare --recorded --json prints the same whole - every class loaded, every file since the autoloader - without a call: Tests/BareTest.php runs it in a process of its own and checks the list.

bare-share (docker/harness/bin/bare-share) does the same for the share links: the autoloader alone, a page shared to every network, the links printed as JSON with what PHP loaded for them past what the autoloader loads by itself - nothing but Omnipost\Share's classes, not even the HTTP client's contracts (Tests/Share/BareTest.php).

The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness volume of the omnipost-harness project.

The entrypoint

docker/entrypoint.sh runs docker/harness/setup.php first: it writes the harness's composer.json and phpunit.xml in the harness volume - this core from its checkout, each provider from its checkout under OMNIPOST_PLUGINS when one is there, from https://github.com/glitchr-omnisense/omnipost-<slug>.git (branch 1.x) otherwise. Then composer install the first time, composer update when composer.json is newer than the lock, and the command: test (PHPUnit), bare, bare-share, composer, sh or php as they are, anything else the console (docker/harness/bin/omnipost, providers by default).

The image (omnisense/omnipost-harness) is built from docker/Dockerfile the first time docker compose run needs it, and the first run installs the packages, which needs the network.

Propose a change docs/harness.md

Writing a provider

A provider is a class that implements Omnipost\ProviderInterface and what it does among PublisherInterface, FeedInterface, Auth\RefreshableInterface and Auth\OAuthInterface, built by a factory that extends Omnipost\ProviderFactory. It can live in the application or be a package of its own, as omnipost/instagram and omnipost/youtube are.

The rules

  • Refuse before sending. publish() validates the post against capabilities() (the core's Validator, plus the rules that depend on the kind) and throws InvalidPostException with every violation, before any request.
  • Say what the network accepts. capabilities() is what a back office shows and checks against: kinds, durations, ratios, sizes, formats, caption and title lengths, hashtags, whether it schedules. It needs no credential.
  • A state, not a promise. publish() answers with the network's id and the state it is in; status($id) reads it again until isFinal(). When the id changes on the way, the answer carries the new one.
  • Nothing required to build. Credentials are read when a call needs them, and their absence is an InvalidConfigException naming them: a site boots before its accounts are connected.
  • The tokens go back to the caller. refresh() and exchange() answer a Token with its end; the provider uses the new one from then on, and stores nothing.
  • Errors. A dead or refused token is UnauthorizedException; any other refusal or a network that cannot be reached a ProviderException with the HTTP status and the network's code; what the network's API cannot do (delete() on Instagram) NotSupportedException. Keep tokens out of messages.
  • Read the variant's post. A provider is given $post->for($platform); it reads the post's fields and options, never variants.
  • No share link. A network's share page is in the core's share table (Share\Registry::defaults(), Share links), not in its provider: a provider is for what needs the network's API. A network with a share page the core lacks gets a Share\NetworkInterface added to the core, or to the application (Share\Registry::add()).

A provider of the application's

A sketch of Mastodon, which publishes a status with the picture's link (no media upload):

<?php // mastodon.php

require __DIR__.'/vendor/autoload.php';

use Omnipost\Config;
use Omnipost\Exception\InvalidConfigException;
use Omnipost\Exception\InvalidPostException;
use Omnipost\Exception\ProviderException;
use Omnipost\Exception\UnauthorizedException;
use Omnipost\Model\Capabilities;
use Omnipost\Model\Media;
use Omnipost\Model\Post;
use Omnipost\Model\PostKind;
use Omnipost\Model\Publication;
use Omnipost\Model\PublicationState;
use Omnipost\Platform;
use Omnipost\ProviderFactory;
use Omnipost\ProviderInterface;
use Omnipost\PublisherInterface;
use Omnipost\Registry;
use Omnipost\Validator;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/** A status on a Mastodon instance, its picture linked (a sketch: no media upload). */
final class MastodonProvider implements PublisherInterface
{
    public function __construct(private readonly HttpClientInterface $http, private readonly string $instance, private readonly ?string $token)
    {
    }

    public function getName(): string { return 'mastodon'; }

    public function getPlatform(): Platform { return Platform::MASTODON; }

    public function capabilities(): Capabilities
    {
        return new Capabilities(kinds: [PostKind::IMAGE, PostKind::REEL], captionMaxLength: 500, mediaMaxCount: 1);
    }

    public function publish(Post $post): Publication
    {
        if ($violations = (new Validator())->validate($post, $this->capabilities())) {
            throw new InvalidPostException($this->getName(), $violations);    // nothing sent
        }
        $status = $this->call('POST', '/api/v1/statuses', ['status' => $post->text()."\n".$post->media[0]->url]);

        return new Publication(Platform::MASTODON, (string) $status['id'], PublicationState::PUBLISHED, $status['url'] ?? null, raw: $status);
    }

    public function status(string $publicationId): Publication
    {
        $status = $this->call('GET', '/api/v1/statuses/'.$publicationId);

        return new Publication(Platform::MASTODON, $publicationId, PublicationState::PUBLISHED, $status['url'] ?? null, raw: $status);
    }

    public function delete(string $publicationId): void
    {
        $this->call('DELETE', '/api/v1/statuses/'.$publicationId);
    }

    private function call(string $method, string $path, ?array $json = null): array
    {
        $token = $this->token ?? throw new InvalidConfigException('The "mastodon" provider needs: token.');
        $response = $this->http->request($method, rtrim($this->instance, '/').$path, ['auth_bearer' => $token] + (null !== $json ? ['json' => $json] : []));
        $status = $response->getStatusCode();
        $data = json_decode($response->getContent(false), true) ?: [];
        if (401 === $status) {
            throw new UnauthorizedException($this->getName(), $data['error'] ?? 'HTTP 401', 401);
        }
        if ($status >= 400) {
            throw new ProviderException($this->getName(), $data['error'] ?? 'HTTP '.$status, $status);
        }

        return $data;
    }
}

final class MastodonProviderFactory extends ProviderFactory
{
    protected function populate(Config $config): void
    {
        $config->defaults(['omnipost.factory_name' => 'mastodon', 'omnipost.required_options' => ['instance'], 'token' => null]);
    }

    protected function build(Config $config): ProviderInterface
    {
        return new MastodonProvider($this->http ?? HttpClient::create(), (string) $config['instance'], $config['token']);
    }
}

$http = new MockHttpClient(fn (string $method, string $url) => new MockResponse(json_encode(['id' => '1131', 'url' => 'https://mastodon.example/@claire/1131'])));
$registry = new Registry([new MastodonProviderFactory($http)], [
    'fediverse' => ['factory' => 'mastodon', 'options' => ['instance' => 'https://mastodon.example', 'token' => getenv('MASTODON_TOKEN') ?: 'test']],
]);
$publication = $registry->get('fediverse')->publish(Post::image(Media::image('https://site.example/poster.jpg'), 'Tonight at 8.', ['violin']));
echo $publication->state->value, ' ', $publication->permalink, "\n";
try {
    $registry->get('fediverse')->publish(new Post(PostKind::CAROUSEL, []));
} catch (InvalidPostException $e) {
    echo $e->getMessage(), "\n";
}
try {
    (new MastodonProviderFactory())->create(['instance' => '']);
} catch (InvalidConfigException $e) {
    echo $e->getMessage(), "\n";
}
$ php mastodon.php
published https://mastodon.example/@claire/1131
[mastodon] kind: carousel posts are not accepted (accepted: image, reel).; media: No media.
The "mastodon" provider needs: instance.

(run on 2026-10-26 on a MockHttpClient, PHP 8.4; no Mastodon instance was called.) The factory's instance is required: it has no sensible default, and an empty one is refused by create(); the token is not, so capabilities() works without it.

In Symfony, the factory is autoconfigured (tagged omnipost.provider_factory) as soon as it is a service, and named in omnipost.providers.<name>.factory (Symfony).

The factory

populate(Config $config) sets, with $config->defaults([...]):

Key
omnipost.factory_name the factory's name, what factory: names
omnipost.required_options the options create() refuses to go without - keep it empty for credentials
every other option its default

Options given as null or '' never reach populate(): the default stands. build(Config $config) makes the provider from the filled config, on $this->http (the client the factory was given) or its own (HttpClient::create(), which needs symfony/http-client). Nothing is called while building.

A package of its own

The shipped providers' layout, to copy:

  • name omnipost/<slug>, namespace Omnipost\<Network>\, PSR-4 from the package root, Tests/ excluded from the class map;
  • require: php >=8.2, omnisense/omnipost ^1.0@dev, symfony/http-client (^6.4|^7.0|^8.0);
  • an Api class that holds the token, adds it to each call, and maps the network's errors; the provider class on top, with validate() public;
  • the endpoints as constants (BASE_URI, VERSION), overridable by options;
  • tests on answers in the shapes the network documents, through a MockHttpClient;
  • a README with what it takes on the network's side (accounts, app review, scopes) and a "Responsibility" section naming the network's terms.

For the bundle to register the factory without the application declaring it, its class must be in OmnipostBundle::FACTORIES; for the harness to install it, in docker/harness/plugins.php and docker/harness/config/providers.php.

Propose a change docs/providers.md

Verified, and not

What the tests prove

Run on 2026-11-09 on the three packages' checkouts (the harness's docker/harness/setup.php, each provider from its checkout), PHP 8.4.27, PHPUnit 11.5.57, no network: 92 tests, 548 assertions, green - the share links' 26 among them (below).

  • The core (Tests/): the registry (each provider built once by name, publishers and feeds among them, a provider built afresh with a token from elsewhere, unknown names and factories, required options checked when asked for, an empty option is its default), capabilities and ratios within their tolerance, posts and their variants (for(), text(), video(), a scheduled copy), tokens (expiry, expiring, to an array and back), the validator rule by rule, the Symfony bundle (Tests/Bridge/OmnipostBundleTest.php: a kernel boots, providers injectable by name, a provider with no token breaks nothing until a call needs it), and the plain-PHP run with no framework class loaded (Tests/BareTest.php).
  • omnipost/instagram (12 tests): a reel in three steps, a container in error, a carousel as a container per item then theirs, a picture and a story, a refused post sends nothing, the feed's kinds, the account, the long-lived token refreshed and used from then, OAuth, the errors mapped, what works without a token, nothing deleted.
  • omnipost/youtube (12 tests): the feed as the uploads playlist with the videos' durations, ISO durations, the account as the channel, the token's own channel without a channel id, a Short uploaded in two steps with one access token drawn, a video with only a URL brought then uploaded, a scheduled video private with its time, the status as the upload's status, a Short without a title refused before any request, tokens and OAuth, a revoked grant unauthorized, what works without credentials.

The answers these tests give the providers are written in the shapes the networks document; they were not recorded from the networks. The examples of these pages were run on 2026-10-26 on the same checkouts with a MockHttpClient.

What is not verified

  • No network was called for these manuals, and the READMEs and the history state no live run: neither a publication on Instagram or YouTube, nor a feed read from a real account, nor an OAuth exchange. The harness can (docker compose run --rm omnipost publish ... with real tokens in docker/.env).
  • Instagram's publishing cap per 24 hours, Meta's App Review and YouTube's audit (videos locked private) are what the READMEs say of the networks; the code does not check them.
  • The Laravel page was written from the registry's API; it was not run in a Laravel application.
  • The defects found while writing these pages, fixed since, each with its test: YouTube's 403 insufficientPermissions not an UnauthorizedException (Errors); Post::$link and Capabilities::$linkInCaption read by no provider (Concepts); ext-mbstring not required by the core.
  • Tests. Tests/Share/ (26 tests, on 2026-11-09 in the harness, PHP 8.4): every network's address for one page, with and without text, picture, hashtags and account; the registry, the JSON; in plain PHP, Composer's autoloader alone, nothing loaded but Omnipost\Share (BareTest). PHPStan at its highest level on Share/, its exceptions and its tests, no error. assets/omnipost-share.js on node's test runner (6 tests green, docker compose run --rm js). The same tests, under Omnishare\, passed in omnisense/omnishare before the merge, where the script was also run in a headless Chrome (the share sheet and the copy revealed, the address copied, the sheet given the title and address, a Mastodon instance asked and opened); the script has not been run in a browser since its attributes were renamed.
  • Live, signed out, on 2026-10-23 in a headless Chrome (The networks): WhatsApp and Telegram show the title and the address on their share page; Facebook, X, Pinterest, LinkedIn, Threads and Reddit keep the address (and, for those that take them, the title, picture, hashtags and account) through their sign-in page; Mastodon's /share exists and asks to sign in first; Bluesky's app recognises /intent/compose and, signed out, leaves it for its home page.
  • In their source, on 2026-10-24 (The networks): Mastodon's /share is its composer behind the sign-in, filled with title, text and url joined by spaces; Bluesky's /intent/compose opens the composer with text for a visitor signed in, and drops it for one signed out.
  • On an application, before the merge: lesringostars built its articles' Facebook, X, Pinterest and e-mail links with omnisense/omnishare, under Symfony, under its Laravel twin and in its React screen. It has not moved to Omnipost\Share yet.
  • Not verified: what each network's composer shows once signed in (no account was used; Mastodon's and Bluesky's read in their code, not seen); Mastodon's return to the share page after its sign-in (Devise's default, read, not seen); the e-mail in a mail client other than the browser's handler; Mastodon instances other than mastodon.social and piaille.fr.

The share links are affiliated with none of the networks they link to; their names and colours are their owners'.

Responsibility

omnisense/omnipost and its omnipost/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by the networks it reaches. Each network keeps its own terms (apps and their review, tokens, publishing limits, what may be posted), named with a link in each provider's README. The MIT licence covers this code only and grants no right to any service or to its data; whoever runs these packages complies with those terms.

Propose a change docs/verified.md