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) |
|---|---|---|
| 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 |
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
- Installation: requirements, a first script in plain PHP, publishing, tokens.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: providers and their interfaces, factories, the registry, every model.
- Share links: the share contract - concepts, the networks, templates, the script.
- Usage: publishing, validating a post, reading an account, tokens and connecting an account.
- Configuration reference: every option of every factory.
- Errors: the exceptions and when each is thrown.
- Testing: testing an application that uses Omnipost.
- The Docker harness: every provider installed, a console to call them.
- Writing a provider.
- Verified, and not.
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 withmb_strlen()). -
omnisense/omnipostrequiressymfony/http-client-contracts(^3.0) and nothing else. -
Each provider package (
omnipost/instagram,omnipost/youtube) requiresomnisense/omnipostandsymfony/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-injectionandsymfony/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, aMockHttpClientin 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(), andcreate($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()andexchange()(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\OmnipostBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnipost.yamland 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
Registryonce, 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 anInvalidPostExceptionlisting 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 throwsNotSupportedExceptionwhere 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 theprivacyoption; 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
-
publish()validates the post, then creates a container:POST /<user>/mediawith the media's public URL - Instagram fetches it. Per kind:Kind Container fields REELmedia_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)IMAGEimage_url,caption,alt_text(the media'salt)STORYmedia_type=STORIES,video_urlorimage_url- no captionCAROUSELone container per item ( is_carousel_item=true,media_type=VIDEO+video_urlfor a video,image_urlfor a picture), thenmedia_type=CAROUSEL,children(their ids, comma-separated),captionVIDEOrefused by the validator (not in Instagram's kinds) Empty fields are left out. It answers
PENDINGwith the container's id. -
status($containerId)readsstatus_code:FINISHEDpublishes it (step 3);ERRORandEXPIREDareFAILEDwith Instagram'sstatusas the error; anything else (IN_PROGRESS) isPROCESSING. -
On
FINISHED,POST /<user>/media_publishwithcreation_id, then the media is read: the answer isPUBLISHEDwith the media's id, its permalink and its time. A container already published (PUBLISHED) answersPUBLISHEDwith 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) aroundstatus()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:
-
the file:
Media::$pathwhen set (its size from the file), elseMedia::$urldownloaded to a temporary file first (any status but 200 is aProviderException); the content type is the media'smime, else the download's, andvideo/*when it is not a video type; -
POST <upload_uri>/videos?uploadType=resumable&part=snippet,statuswith the metadata:Field Value snippet.titlethe post's title snippet.descriptionPost::text(): the caption and the hashtagssnippet.tagsthe tags without #(left out when none)snippet.categoryIdthe post's category_idoption, else the provider's (left out when none)status.privacyStatusprivatewhen scheduled; else the post'sprivacyoption, else the provider's (privateby default)status.selfDeclaredMadeForKidsfalsestatus.publishAtthe post's scheduledAtin UTC, when it is in the future -
PUTof 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 aCapabilitiescan say, the same for every network.$provider->validate(Post $post)(Instagram and YouTube) adds what depends on the post's kind, and is whatpublish()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():
| 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 aVIDEO.
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
account():GET /<user>(meby default) with the id, username, name, picture, followers, posts count and account type;urlishttps://www.instagram.com/<username>/.feed($cursor, $limit):GET /<user>/media,limitbetween 1 and 100,afterthe cursor.nextis theaftercursor only when Instagram says there is a next page (paging.next): the last page has cursors too, and givesnull.- Kinds:
VIDEOisREELwhenmedia_product_typeisREELS(or missing), elseVIDEO;CAROUSEL_ALBUMisCAROUSEL, with its children as items of their own; anything else isSTORYwhenmedia_product_typeisSTORY, elseIMAGE. urlis the media (media_url);thumbnailUrlisthumbnail_url, else for a picture the picture itself, for a carousel its first child's;likesandcommentsas Instagram gives them;durationandviewsare 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, bychannel_idwith the API key, or the token's own channel (mine=true) when nochannel_idis set. The username is the channel's handle (customUrl,@claireviolin), else its title;urlhttps://www.youtube.com/<handle>(or/channel/<id>);followersthe subscribers, null when the channel hides them;poststhe videos count. The channel is read once per provider object.feed($cursor, $limit): the channel's uploads playlist (playlistItems,maxResultsbetween 1 and 50,pageTokenthe cursor), then onevideos?part=contentDetails,statisticscall for the page's durations and counts. A video of 180 seconds at most is aREEL, a longer one aVIDEO(the API does not tell a Short, nor its ratio).permalinkis the watch page,urlthe embed player (https://www.youtube.com/embed/<id>),captionthe video's title, the thumbnail the largest given,publishedAtthe 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
| 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()callsGET /refresh_access_token?grant_type=ig_refresh_tokenand 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 (expiresAtnull) unless it came fromrefresh()orexchange(). - YouTube: the access token lives an hour, drawn from the refresh token (
client_id,client_secret,refresh_tokenoptions). 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. Anaccess_tokenoption 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')),
);
}
Shareablechecks that its address and its picture are whole http(s) addresses (InvalidShareableExceptionotherwise): 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;nullgives every network. An unknown name throwsUnknownNetworkException.Share::url('x'),Share::link('x'),Share::toArray()(alsojson_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().
The pages on share links
- Concepts:
Shareable,Registry,Share,Link,NetworkInterface, the labels. - The networks: every address, and how each was checked.
- Templates: Twig, Blade and React, one JSON.
- The script: share sheet, copy, Mastodon instance.
- Errors, testing, the harness and what is verified.
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 |
Share and Link
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 |
|---|---|---|
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 |
https://pinterest.com/pin/create/button/?url=…&media=…&description=… |
www.pinterest.com's "Pin Builder" sign-in, the three parameters kept. Verified | |
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 |
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 |
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 | |
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.rbhasresource :share, only: [:show];SharesControllerrunsbefore_action :authenticate_user!(the 302 to the sign-in seen live) and rendersshares/show, which mounts the standalone composer (entrypoints/share.tsx).ApplicationHelper#render_initial_stategives ittext: [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 onetextbuilt 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_locationfor a GET page): read, not seen. - Bluesky (bluesky-social/social-app, branch main at 4e4ad9c5f):
src/lib/hooks/useIntentHandler.tsreads/intent/composeand itstext(alsoimageUrisandvideoUri, local files of the native app only: anhttp(s)address is filtered out, and the web app never takes images).useComposeIntentbegins withif (!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
}
Share links
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()andgetRequestOptions()give what was sent: the container's fields for Instagram (a form body), the metadata for YouTube (a JSON body).- A
MockHttpClientbuilt with an empty list fails on any request: a test that a refused post sends nothing needs no assertion of its own beyondgetRequestsCount(). - 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
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 againstcapabilities()(the core'sValidator, plus the rules that depend on the kind) and throwsInvalidPostExceptionwith 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 untilisFinal(). 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
InvalidConfigExceptionnaming them: a site boots before its accounts are connected. - The tokens go back to the caller.
refresh()andexchange()answer aTokenwith 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 aProviderExceptionwith 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 andoptions, nevervariants. - 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 aShare\NetworkInterfaceadded 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>, namespaceOmnipost\<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
Apiclass that holds the token, adds it to each call, and maps the network's errors; the provider class on top, withvalidate()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 indocker/.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
insufficientPermissionsnot anUnauthorizedException(Errors);Post::$linkandCapabilities::$linkInCaptionread by no provider (Concepts);ext-mbstringnot required by the core.
Share links
- 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 butOmnipost\Share(BareTest). PHPStan at its highest level onShare/, its exceptions and its tests, no error.assets/omnipost-share.json node's test runner (6 tests green,docker compose run --rm js). The same tests, underOmnishare\, passed inomnisense/omnisharebefore 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
/shareexists and asks to sign in first; Bluesky's app recognises/intent/composeand, signed out, leaves it for its home page. - In their source, on 2026-10-24 (The networks):
Mastodon's
/shareis its composer behind the sign-in, filled withtitle,textandurljoined by spaces; Bluesky's/intent/composeopens the composer withtextfor 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 toOmnipost\Shareyet. - 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.