Aller au contenu

omnisense/omnimeet

Omnimeet: one contract for video meetings - open a room, let a participant in (a JavaScript engine and its options, an embedded frame, or an address), close it, read its state, hear the provider's callbacks - whatever carries the call: the two browsers themselves (omnimeet/direct) or a conferencing service (omnimeet/jitsi); and its Symfony bundle.

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnimeet:1.x-dev

Omnimeet is one PHP contract for video meetings: open a room and let someone in, whatever carries the call - the two browsers themselves, or a conferencing service. An application asks every gateway the same questions - open this meeting, how does this person enter it now, close it, where does it stand, what is this callback - and gets the same answers back: a Meeting with its reference on the gateway, an Access the page renders (an engine to run, a frame to show, an address to go to), a Notification.

$gateway = $registry->get('direct');                       // or 'group', a Jitsi instance: the same calls

$meeting = $gateway->open(new Meeting($token, $startsAt, $endsAt));
$meeting->reference;                                       // the room on that gateway: keep it

$access = $gateway->join($meeting, Participant::guest('u42'));
$access->kind;                                             // ENGINE, FRAME or LINK
$access->origins;                                          // the third parties the browser will talk to: [] for direct

$gateway->close($meeting);                                 // where the gateway can: supports(Close::class)

The design is the family's (Payum's): a gateway's factory fills a configuration with its name, its capabilities, its options, its client and one action per request it answers; the gateway runs the action that supports each request; a registry holds the application's gateways by name. The core requires PHP alone - no framework, no library - and so do its two gateways: neither calls anything over the network (a direct meeting is carried by the two browsers, a Jitsi room exists when its first participant comes in, a token is signed on this side). A Symfony bundle is included (Symfony).

Published as omnisense/omnimeet, formerly omniscient/omnimeet and before that glitchr/omnimeet: the package replaces the old names. The namespace is Omnimeet\; gateways keep the omnimeet/ vendor and the Omnimeet\<Gateway>\ namespace.

When to use it

  • An application that holds video meetings - a consultation, an interview, a class - and wants the call carried by the browsers alone (two people, no third party) or by a conferencing service (several people), without rewriting its pages when it changes.
  • A record of the meeting kept by the application: who may enter, when, the waiting room. The gateway only opens the room and lets someone in.

It is not a calendar, a chat, a recorder nor a waiting room, and it decides nothing: who may enter and when is the application's to check before asking. It is told a key, a start and an end, and of each person a role and an opaque identifier - a name or a title only when the application gives one, never an account.

Gateways

Package Factory name The call Third party Open Join Close Fetch Notify
omnimeet/direct direct from browser to browser (WebRTC): two people, end to end, nothing recorded; the application relays the handshake; a TURN relay of its own when a network forbids the direct way none yes yes (engine) yes yes -
omnimeet/jitsi jitsi a Jitsi Meet instance (one's own, a public one, Jitsi as a Service): several people, an embedded frame driven from the page, a token per participant where the instance checks one the instance yes yes (frame, or link) - - with webhook_secret (JaaS)

The capabilities are what each gateway's supports() answers (probed in the harness on 2026-10-26, bare).

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omnimeet omnimeet/direct  # from browser to browser: no key, no third party
composer require omnimeet/jitsi                       # a Jitsi Meet instance

Requirements

  • PHP 8.2 or later; omnisense/omnimeet requires nothing else, nor does omnimeet/direct.
  • omnimeet/jitsi suggests ext-openssl, for tokens signed with a private key (RS256: Jitsi as a Service, or an instance that validates against a key server).

Omnimeet needs no framework, and no library either: the core requires PHP alone, and so do the two gateway packages - neither calls anything (a direct meeting is carried by the two browsers, a Jitsi room exists when its first participant comes in, a token is signed on this side). It 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 // meet.php

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

use Omnimeet\Direct\DirectGatewayFactory;
use Omnimeet\Direct\InMemorySignalStore;
use Omnimeet\Jitsi\JitsiGatewayFactory;
use Omnimeet\Model\AccessKind;
use Omnimeet\Model\Meeting;
use Omnimeet\Model\Participant;
use Omnimeet\Registry;

$registry = new Registry([new DirectGatewayFactory(new InMemorySignalStore()), new JitsiGatewayFactory()], [
    'direct' => ['factory' => 'direct', 'options' => ['turn_secret' => 'the-relay-s-secret', 'turn_urls' => ['turn:turn.example.org:3478']]],
    'group' => ['factory' => 'jitsi', 'options' => ['domain' => 'meet.example.org', 'app_id' => 'my_app', 'app_secret' => 'the-instance-s-secret']],
]);

// The application's meeting: its own key - unguessable -, when it starts and ends.
$planned = new Meeting(bin2hex(random_bytes(16)), new DateTimeImmutable('+5 minutes'), new DateTimeImmutable('+35 minutes'));

foreach ($registry->all() as $name => $gateway) {
    $capabilities = $gateway->capabilities();
    $meeting = $gateway->open($planned);                                  // $meeting->reference: what to keep
    $access = $gateway->join($meeting, Participant::host('u42', 'Dr Tilleul', 'fr'));

    printf("%s (%s): %s, %s\n", $name, $gateway->getTitle(), $capabilities->maxParticipants ? $capabilities->maxParticipants.' people' : 'several people', $capabilities->thirdParty ? 'through a third party' : 'no third party');
    printf("  reference %s\n", $meeting->reference);
    echo match ($access->kind) {
        AccessKind::ENGINE => sprintf("  run %s with %s; ICE servers: %s\n", basename($access->script), json_encode(array_diff_key($access->options, ['iceServers' => 1])), implode(', ', array_merge(...array_column($access->options['iceServers'], 'urls')))),
        AccessKind::FRAME => sprintf("  frame %s\n  (asks %s; the token lapses at %s)\n", strtok($access->url, '?'), implode(', ', $access->origins), $access->expiresAt?->format('H:i')),
        AccessKind::LINK => sprintf("  send them to %s\n", $access->url),
    };
}
$ php meet.php
direct (Direct): 2 people, no third party
  reference d9ab80f93063685d8b1b41eb74525232
  run visio.js with {"room":"d9ab80f93063685d8b1b41eb74525232","participant":"u42","role":"host"}; ICE servers: turn:turn.example.org:3478
group (Jitsi Meet): several people, through a third party
  reference f2d270427b62a8d67a39c90455382499
  frame https://meet.example.org/f2d270427b62a8d67a39c90455382499
  (asks https://meet.example.org; the token lapses at 06:43)

(as answered on 2026-10-05, and the same again on 2026-10-26 with PHP 8.4.27 on the workspace's checkouts and no network - the references and the token's time change with each run; nothing was called)

That is all there is to it:

  • a factory per gateway package (DirectGatewayFactory, JitsiGatewayFactory), which takes what its gateway works with - for the direct one, the store its handshake waits in (omnimeet/direct);
  • the registry, built by hand from the factories and the gateways' options, by name;
  • the gateways it gives, each answering the same five questions: open(), join(), close(), fetch(), notify() (gateways).

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 omnimeet bare (harness); docker compose up there serves a call between two tabs from PHP's own web server.

What the application does around a gateway

A gateway opens a room and lets someone in. Everything else is the application's:

  1. Decide who may enter, and when - before asking. A gateway checks nobody.
  2. Open the meeting once and keep its reference ($meeting->reference) with its own record of the meeting, and the gateway's name.
  3. On the participant's page, join - each time: an Access is made for one person, now (a token, temporary credentials) - and render what comes back (gateways): an engine to run, a frame to show, or an address to send them to.
  4. Close when it is over, where the gateway can (supports(Close::class)).
use Omnimeet\Model\Meeting;
use Omnimeet\Model\Participant;
use Omnimeet\Request\Close;

$gateway = $registry->get('direct');

// once, when the meeting is planned
$meeting = $gateway->open(new Meeting($row->token, $row->startsAt, $row->endsAt));
$row->reference = $meeting->reference;

// on each participant's page, once the application let them through
$meeting = new Meeting($row->token, $row->startsAt, $row->endsAt, reference: $row->reference);
$access = $gateway->join($meeting, Participant::guest('u'.$user->id));

// when it is over
if ($gateway->supports(Close::class)) {
    $gateway->close($meeting);
}

What a gateway is told is what these models carry and no more: a key (make it unguessable - it names the room on a provider), a start and an end, a title if you give one, and of each person a role, an opaque identifier and a name if you give one. Never an account, never an e-mail.

In a framework

  • Symfony: Omnimeet\Bridge\Symfony\OmnimeetBundle registers the factories, builds the registry from config/packages/omnimeet.yaml and makes each gateway 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. omnibase/office is built on it: its video room keeps the room, the waiting room and the pages, and asks a gateway for the rest.
  • Laravel: no bridge package exists; a service provider builds the registry once: Laravel.
  • Any other: build the Registry once, where the framework builds its services (a service provider, a container definition), as the script above does.

Errors

Exception When
NotSupportedException the gateway does not do that: supports() says so beforehand
InvalidConfigException a gateway not configured, a factory not installed, an option missing, a meeting not opened yet
InvalidNotificationException a callback whose signature does not hold, or too old
ProviderException the provider refused or could not be reached

All implement Omnimeet\Exception\OmnimeetException; Errors has each one's parent and where it is thrown.

Proposer une modification docs/installation.md

Symfony

Omnimeet runs without a framework (installation); in a Symfony application its bundle does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel - are not required by omnisense/omnimeet: the application has them, and nothing of them is loaded outside Symfony.

Register Omnimeet\Bridge\Symfony\OmnimeetBundle (no Flex recipe):

// config/bundles.php
return [
    // ...
    Omnimeet\Bridge\Symfony\OmnimeetBundle::class => ['all' => true],
];
# config/packages/omnimeet.yaml
omnimeet:
    gateways:                      # by name: a factory and its options
        direct:
            factory: direct
            options:
                turn_secret: '%env(default::TURN_SECRET)%'
                turn_urls: '%env(default::TURN_URLS)%'       # one variable, comma-separated
        group:
            factory: jitsi
            options:
                domain: '%env(JITSI_DOMAIN)%'
                app_id: '%env(default::JITSI_APP_ID)%'
                app_secret: '%env(default::JITSI_APP_SECRET)%'

The configuration tree

As the bundle's configure() declares it (dumped with Symfony's YamlReferenceDumper, the dumper config:dump-reference omnimeet uses):

omnimeet:

    # The gateways, by name: a factory (direct, jitsi...) and its options.
    gateways:

        # Prototype
        name:
            factory:              ~ # Required
            options:              []
Key Type Default
gateways.<name>.factory string required direct, jitsi, or an application factory's getName()
gateways.<name>.options any [] handed whole to the factory's create() (Configuration)

What the bundle registers

Service
Omnimeet\Direct\DirectGatewayFactory, Omnimeet\Jitsi\JitsiGatewayFactory private, autowired, tagged omnimeet.gateway_factory each when its package is installed
Omnimeet\Registry public every service tagged omnimeet.gateway_factory, the configured gateways
omnimeet.gateway.<name> private Registry::get('<name>'), autowired as GatewayInterface $<name>

Any service of the application implementing Omnimeet\GatewayFactoryInterface is tagged omnimeet.gateway_factory by autoconfiguration. The bundle defines no command, route, event or Twig function: the signal endpoints of the direct gateway, the page and its script are the application's.

The services compiled for two gateways (direct, and group on jitsi with a domain) and a SignalStoreInterface service, then the registry's factories and the gateways' titles:

Omnimeet\Direct\DirectGatewayFactory
Omnimeet\Jitsi\JitsiGatewayFactory
Omnimeet\Registry
direct, jitsi | Direct, Jitsi Meet

(run on 2026-10-26, Symfony 8 DependencyInjection, the workspace's checkouts)

Factories and what they are given

Every omnimeet/* package installed registers its factory, autowired: what its constructor asks for is the application's service of that type. omnimeet/direct asks for a SignalStoreInterface - alias it to your store:

#[AsAlias(SignalStoreInterface::class)]
final class SignalRepository extends ServiceEntityRepository implements SignalStoreInterface { /* ... */ }

Without such a service the factory's argument is left out (it is nullable) and the handshake is kept in the PHP process's memory (InMemorySignalStore), which forgets it between two requests: under PHP-FPM a direct call then never connects.

What is autowired:

Service
GatewayInterface $direct, GatewayInterface $group one gateway by the argument's name (the configured name)
Registry every configured gateway by name (get(), has(), names(), options(), create())

Nothing is built when the container compiles: a gateway is built the first time it is asked for, and an option left empty only shows then (InvalidConfigException).

An application's own gateway - a class implementing GatewayFactoryInterface - is registered too, autoconfigured, and can be named as a factory.

final class MeetingController extends AbstractController
{
    #[Route('/meetings/{token}', name: 'app_meeting')]
    #[IsGranted('MEETING_ENTER', 'room')]                       // who may enter is the application's to say
    public function __invoke(Room $room, Registry $gateways): Response
    {
        $gateway = $gateways->get($room->getGateway());
        $access = $gateway->join(
            new Meeting($room->getToken(), $room->getStartsAt(), $room->getEndsAt(), reference: $room->getReference()),
            new Participant('u'.$this->getUser()->getId(), $room->isHost($this->getUser()) ? Role::HOST : Role::GUEST),
        );

        return $this->render('meeting/room.html.twig', ['room' => $room, 'access' => $access]);
    }
}

omnibase/office does all of this for a professional practice: its Visio\Rooms opens a room on office.visio.gateway, its page renders the Access, its SignalRepository is the direct gateway's store.

Proposer une modification docs/symfony.md

Laravel

There is no Laravel bridge for Omnimeet: omnibridge/laravel has none, and this package ships no service provider. None is needed for it to run - the core and its gateways need no framework - so a service provider of the application builds the registry once, by hand.

A service provider

namespace App\Providers;

use App\Meetings\DatabaseSignalStore;            // the application's SignalStoreInterface
use Illuminate\Support\ServiceProvider;
use Omnimeet\Direct\DirectGatewayFactory;
use Omnimeet\Direct\Signaling;
use Omnimeet\Jitsi\JitsiGatewayFactory;
use Omnimeet\Registry;

final class MeetingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(DirectGatewayFactory::class, fn ($app) => new DirectGatewayFactory($app->make(DatabaseSignalStore::class)));
        $this->app->singleton(Signaling::class, fn ($app) => $app->make(DirectGatewayFactory::class)->signaling());

        $this->app->singleton(Registry::class, fn ($app) => new Registry(
            [$app->make(DirectGatewayFactory::class), new JitsiGatewayFactory()],
            [
                'direct' => ['factory' => 'direct', 'options' => [
                    'turn_secret' => config('services.turn.secret'),
                    'turn_urls' => config('services.turn.urls'),          // a list, or one comma-separated string
                ]],
                'group' => ['factory' => 'jitsi', 'options' => [
                    'domain' => config('services.jitsi.domain'),
                    'app_id' => config('services.jitsi.app_id'),
                    'app_secret' => config('services.jitsi.app_secret'),
                ]],
            ],
        ));
    }
}

The direct gateway's store must outlive a request (a table, a cache): the handshake is posted by one request and read by another. Its two endpoints are routes of the application, which check that the caller is one of the meeting's participants before calling Signaling::send() or receive() (omnimeet/direct).

The script

$access->script is a file inside the gateway's package (vendor/omnimeet/direct/public/js/visio.js, vendor/omnimeet/jitsi/public/js/jitsi.js): copy it to public/ at build time, or answer it from a route, as for any other asset. Nothing of a gateway is loaded from elsewhere for an ENGINE.

Proposer une modification docs/laravel.md

Models

All models are readonly; times are DateTimeImmutable. They carry what a gateway needs and nothing of the application: no account, no entity, no reason for the meeting.

Meeting

Property
key the application's own name for the meeting. It names the room on a provider: make it unguessable (a random token), not appointment:12
startsAt, endsAt optional; a token lapses a while after the end
title optional: shown by a provider that shows one. Leave it out and nothing says what the meeting is about
reference the gateway's name for it, once opened: what the application keeps
status Status: where it stands
present list<Participant>: who is in, when the gateway can tell (fetch())

with(reference, status, present) gives the same meeting as its gateway has it now; reference() throws InvalidConfigException on a meeting that was never opened.

Participant

Property
id opaque, stable for the meeting: u42, a hash - what the application finds its own account by. Never an e-mail
role Role::HOST (holds the meeting: starts it, ends it, moderates where the gateway has moderators) or Role::GUEST
name shown to the others; null: the gateway's default, or none
locale the language of a provider's interface: fr, de

Participant::host($id, $name, $locale), Participant::guest(...).

Access

How one participant enters one meeting: what join() answers and the page renders. Made for that person at that moment; asked again each time, never stored.

Property
kind AccessKind::ENGINE, FRAME or LINK
engine, script the name of the gateway's script (direct, jitsi) and its file inside the package: the application serves it
options what the script starts with: a JSON document
url FRAME: the address to frame; LINK: where to send the participant
origins the third parties the participant's browser will talk to (https://meet.example.org): empty when the call stays between the participants and the application
permissions what the page or the frame needs from the browser: camera, microphone, display-capture; allow() gives a frame's allow attribute
expiresAt when its credentials lapse; isExpired()

isThirdParty() is the question to ask before loading anything: name the third party to the visitor, and wait for their consent.

Capabilities

What a gateway's meetings are, said before one is opened ($gateway->capabilities()).

Property direct jitsi
maxParticipants 2 null: the instance's own limit
thirdParty no yes (unless the instance is yours)
endToEnd yes no: the instance's bridge carries the media
recording no when features.recording is on (JaaS)
screenSharing, chat yes yes
tokens no when the instance checks one
access [ENGINE] [FRAME, LINK]

takes($n), letsInBy(AccessKind), toArray(). The operations a gateway answers are asked of the gateway: supports(Close::class).

Status

PLANNED (not opened on a gateway), OPEN (opened; nobody talking yet, as far as the gateway knows), LIVE (at least two connected), ENDED, UNKNOWN. admits(): whether someone may still be let in; isOver().

Notification

A provider's callback, read as one event about one meeting, its signature checked (notify($body, $headers)).

Property
event Event: OPENED, JOINED, LEFT, ENDED, RECORDING_STARTED, RECORDING_ENDED, RECORDING_READY, OTHER
type the provider's own name for it
reference the meeting, as open() named it
participant who, for JOINED and LEFT: the identifier the application gave
at, id when; and what a callback sent twice is recognised by
data the provider's payload

Proposer une modification docs/models.md

Gateways

One contract, Omnimeet\GatewayInterface, whatever carries the call:

Method Request Answers
open(Meeting) Request\Open the meeting with its reference on the gateway. The same key, the same room
join(Meeting, Participant) Request\Join an Access: how that participant enters, now
close(Meeting) Request\Close the meeting, ENDED: nobody gets in, what the gateway kept goes
fetch(Meeting) Request\Fetch the meeting with its status and who is present
notify($body, $headers) Request\Notify a Notification, once the callback's signature holds

capabilities() says what its meetings are; supports(Request::class) what it answers. Each method is a shortcut for execute(new Request\...). What a gateway does not do throws NotSupportedException - nothing is guessed, nothing silently skipped.

omnimeet/direct omnimeet/jitsi
The call from browser to browser (WebRTC), two people, end to end on a Jitsi Meet instance, several people
Third party none the instance
open the room is the key names the room; nothing is asked of the instance
join ENGINE: visio.js, the participant's side, their ICE servers FRAME: the instance's address and token, jitsi.js to drive it
close purges the handshake not supported: a moderator's page ends the conference
fetch reads the handshake: who is there, LIVE once answered not supported
notify not supported: nobody to send any JaaS webhooks, with webhook_secret
Needs a store for the handshake; a TURN relay of your own, optional an instance; its secret or key pair when it checks tokens

A gateway does not decide who may enter, nor when, nor does it know who waits: that is the application's, on its own record of the meeting - the same whatever the gateway.

The contract, member by member

Omnimeet\GatewayInterface:

Method
getName(): string, getTitle(): string the factory's name (direct, jitsi) and title (Direct, Jitsi Meet)
capabilities(): Capabilities what its meetings are (Models)
supports(string $request): bool whether an action answers that request class; asked with a probe made without the request's constructor
execute(Request $request): Request the request, answered by the first action that supports it; NotSupportedException when none does, ProviderException when the action left it unanswered
open(Meeting): Meeting, join(Meeting, Participant): Access, close(Meeting): Meeting, fetch(Meeting): Meeting, notify(string $body, array $headers = []): Notification execute() with Request\Open, Join, Close, Fetch, Notify

Omnimeet\Gateway (final) is the implementation every factory builds: a name, a title, the capabilities and the actions.

Requests

Omnimeet\Request\Request (abstract): setResult(), getResult(), isAnswered(). Each request is final with public readonly properties:

Request Properties Result
Open meeting getMeeting(): the meeting with its reference and Status::OPEN; the same key gives the same reference
Join meeting, participant getAccess()
Close meeting getMeeting(): Status::ENDED
Fetch meeting getMeeting(): its status and who is present
Notify body, headers getNotification(); header($name) reads a header whatever its case (the first value of a list)

join(), close() and fetch() need an opened meeting: Meeting::reference() throws InvalidConfigException for one without a reference.

The factory and its Config

Omnimeet\GatewayFactory (abstract) has no constructor of its own - a gateway that needs something (a store, an HTTP client) takes it in its factory's constructor. create(array $options = []):

  1. fills an Omnimeet\Config (an ArrayObject) with the options given, then the factory's defaults (populateConfig(), through Config::defaults()) under them;
  2. checks omnimeet.required_options: one missing or empty (null, '', []) is an InvalidConfigException naming them all (The "jitsi" gateway needs: domain.);
  3. builds the client from omnimeet.api and the capabilities from omnimeet.capabilities (each a value or a closure of the Config); no Capabilities is an InvalidConfigException;
  4. collects every omnimeet.action.<name> (a closure of the Config is called; one that gives anything but an action - null - is left out), hands the client to the actions implementing ApiAwareInterface (ApiAwareTrait checks its class), and returns a Gateway.

Config::get($key, $default), validateNotEmpty($keys), and list($key): a list option given as one comma-separated string (an environment variable) split into its values.

The registry

new Omnimeet\Registry(iterable $factories, array $config), $config the gateways by name (['<name>' => ['factory' => '<factory name>', 'options' => [...]]]):

Method
get(string $name): GatewayInterface built on first use, then kept; No "x" gateway; configured: ... and No "y" factory for the "x" gateway; installed: ... as InvalidConfigException
has($name), names(), options($name), factories()
create(string $name, array $overrides = []) a gateway built afresh, $overrides over its options; not kept
all() every gateway, built, by name

Rendering an Access

$access = $gateway->join($meeting, $participant);

match ($access->kind) {
    AccessKind::ENGINE => /* the page's own markup + the gateway's script */,
    AccessKind::FRAME  => /* a frame on $access->url, or driven by the gateway's script */,
    AccessKind::LINK   => /* a button to $access->url */,
};
  • The script is a file of the gateway's package ($access->script, an absolute path): serve it yourself - copy it to the public directory at build time, or answer it from a route. Nothing is loaded from elsewhere for an ENGINE.
  • A script boots on its own root, [data-omnimeet-<engine>] (data-omnimeet-direct, data-omnimeet-jitsi), and takes $access->options from it: each package's documentation gives its markup. A script that drives a frame puts it in [data-omnimeet-frame], is told omnimeet:join, omnimeet:leave, omnimeet:end (events on its root) and says where it stands with omnimeet:state ({state}): the page's own controls stay the same from one gateway to the other.
  • A third party is named first. When $access->isThirdParty(), $access->origins lists who the participant's browser will talk to: say so, wait for consent, and only then load the frame. They also go in the page's Permissions-Policy (camera=(self "https://meet.example.org")) and its Content-Security-Policy (frame-src, script-src); $access->allow() is the frame's allow attribute.
  • Ask again each time. An Access carries credentials made for one person ($access->expiresAt): never cache it, never show it to anyone else.

The state of a meeting

fetch() answers what the gateway itself knows, which may be little: the direct one reads its handshake (who said something, whether an answer was given), Jitsi publishes nothing to ask. Presence, a waiting room, "the host has arrived": keep them in the application - a heartbeat from the page - and they work for every gateway.

Callbacks

// the webhook's controller
try {
    $notification = $registry->get('group')->notify($request->getContent(), $request->headers->all());
} catch (InvalidNotificationException) {
    return new Response('', 400);
}
if ($seen->has($notification->id)) {   // the same callback, sent twice
    return new Response('', 200);
}
match ($notification->event) {
    Event::JOINED => ..., Event::LEFT => ..., Event::ENDED => ..., Event::RECORDING_READY => ..., default => null,
};

Writing a gateway

A package omnimeet/<provider>: a factory extending Omnimeet\GatewayFactory that fills a Config - its name, title, capabilities, the options it needs, its client (omnimeet.api) and one action per request it answers:

final class WherebyGatewayFactory extends GatewayFactory
{
    // A provider reached over HTTP takes its client here: the application's, a mock in a test.
    public function __construct(private readonly HttpClientInterface $http)
    {
    }

    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnimeet.factory_name' => 'whereby',
            'omnimeet.factory_title' => 'Whereby',
            'omnimeet.required_options' => ['api_key'],
            'omnimeet.capabilities' => new Capabilities(thirdParty: true, access: [AccessKind::FRAME]),
            'omnimeet.api' => fn (Config $c) => new Api((string) $c['api_key'], $this->http),
            'omnimeet.action.open' => new OpenAction(),
            'omnimeet.action.join' => new JoinAction(),
            'omnimeet.action.close' => new CloseAction(),
        ]);
    }
}

An action implements Action\ActionInterface (supports(Request), execute(Request) which sets the request's result) and, to be handed the client, Action\ApiAwareInterface. Leave out the actions the provider's documentation does not show: the gateway then says it does not support them.

Proposer une modification docs/gateways.md

Configuration reference

The registry

new Omnimeet\Registry(iterable $factories, array $config);
// $config: ['<name>' => ['factory' => '<factory name>', 'options' => [...]], ...]

Each gateway: factory (string, required: a factory's getName()) and options (array, [] by default, handed to the factory's create() on first use; the gateway is then kept). Nothing is checked before a gateway is asked for. The Symfony bundle's tree (omnimeet.gateways.<name>.factory, .options) is the same (Symfony).

direct

new DirectGatewayFactory(?SignalStoreInterface $signals = null): the store the handshake waits in (InMemorySignalStore when none is given: one PHP process only).

Option Type Default Required
turn_secret ?string null no coturn's static-auth-secret: the participants' temporary credentials are signed with it
turn_urls list or comma-separated string [] no the relay's addresses (turn:host:3478?transport=udp...); with turn_secret, the relay is given to each participant
stun_urls list or comma-separated string [] no STUN servers; none by default, so no third party is asked where the browser is
turn_ttl int 3600 no seconds the credentials last (at least 60)

No option is required: without a relay the two browsers connect directly, or not at all.

jitsi

new JitsiGatewayFactory().

Option Type Default Required
domain string - yes the instance's host (meet.example.org, 8x8.vc); a scheme and a trailing slash are dropped, anything else than a host (and port) is an InvalidConfigException
app_id ?string null with app_secret, and for JaaS Prosody's app_id (the token's iss); JaaS: the AppID (vpaas-magic-cookie-...)
app_secret ?string null no Prosody's app_secret: tokens signed HS256
private_key ?string null for JaaS a PEM, or the path of its file: tokens signed RS256 (needs ext-openssl)
key_id ?string null with private_key the token's kid (JaaS: <AppID>/<key id>)
jaas ?bool null no Jitsi as a Service; told from the domain 8x8.vc or an AppID starting vpaas-magic-cookie- when null
audience ?string null no the token's aud (jitsi when null); self-hosted only
issuer ?string null no the token's iss (app_id when null); self-hosted only
subject ?string null no the token's sub (the domain without its port when null: a tenant otherwise); self-hosted only
features array recording, livestreaming, transcription, outbound-call all false no JaaS: the token's context.features; recording: true also sets Capabilities::$recording
webhook_secret ?string null for notify() JaaS: the webhook endpoint's signing secret; without it notify() is not supported
webhook_tolerance int 300 no seconds a callback's timestamp may differ from now; 0: any age
token_ttl int 7200 no seconds a token lasts when the meeting has no end (at least 60); with an end in the future, it lapses five minutes after the end
room_prefix string '' no before the room's name (lower-cased)
room_secret ?string null no the room's name an HMAC-SHA256 of the key with it, instead of a SHA-256: set it when meeting keys can be guessed
config array disableInviteFunctions: true, disableThirdPartyRequests: true no config.js overrides (configOverwrite; in the address after the #, flattened, config.<key>=<JSON>)
interface_config array [] no interface_config.js overrides
embed bool true no false: the instance forbids being framed - join() answers a LINK

The instance's kind follows from the options: no app_secret and no private_key, an open instance (the room's name is the only secret); one of them, a token per participant; jaas, JaaS's claims and rooms under the AppID. Inconsistent options (a secret without app_id, a key without key_id, JaaS without app_id, private_key and key_id) are an InvalidConfigException when the gateway is built.

Proposer une modification docs/configuration.md

Errors

Omnimeet\Exception\OmnimeetException (an interface, \Throwable)
├── InvalidConfigException         extends \LogicException             a gateway misconfigured, a meeting not opened
├── InvalidModelException          extends \InvalidArgumentException   a model built with what it cannot hold
├── NotSupportedException          extends \LogicException             the gateway does not do that
├── ProviderException              extends \RuntimeException           the provider refused, or could not be reached
│   └── InvalidNotificationException                                   a callback that is not the provider's
└── Omnimeet\Direct\Exception\InvalidSignalException  extends \InvalidArgumentException   (omnimeet/direct) not a message of the handshake

ProviderException carries provider (the gateway's name) and providerCode (?string); its message starts with [<provider>] .

InvalidConfigException

Message Thrown by
The "<factory>" gateway needs: <options>. GatewayFactory::create(): a required option missing or empty
The "<factory>" factory does not say what its meetings are (omnimeet.capabilities). GatewayFactory::create(): a factory without capabilities
No "x" gateway; configured: ..., No "y" factory for the "x" gateway; installed: ... Registry::get(), Registry::create()
The meeting "<key>" is not opened on a gateway yet: open() gives its reference. Meeting::reference(), so join(), close(), fetch() of a meeting without a reference
<Action> needs a <Api>, got <class>. ApiAwareTrait::setApi()
TURN is not configured: ... direct: TurnCredentials::issue() without a secret or an address (join() asks only when configured)
The jitsi gateway's domain is a host name ..., Jitsi as a Service needs app_id ..., private_key and key_id ..., A token signed with a private key names its public key: ... key_id., A token signed with app_secret names its application: ... app_id. jitsi: when the gateway is built
Signing a token with a private key (RS256) needs PHP's openssl extension., The private key of the jitsi gateway cannot be read: a PEM, or the path of its file., Tokens are signed with HS256 or RS256, not "x". jitsi: Jwt::sign(), on join()

NotSupportedException

The "jitsi" gateway does not support Close. - from Gateway::execute() for a request no action supports; supports() says it beforehand:

Open Join Close Fetch Notify
direct yes yes yes yes not supported
jitsi yes yes not supported not supported only with webhook_secret

ProviderException and InvalidNotificationException

ProviderException is thrown by Gateway::execute() for an action that left its request unanswered. Neither gateway calls a provider over the network today, so no other comes from them.

InvalidNotificationException (jitsi's notify()): no webhook_secret; no X-Jaas-Signature (t=...,v1=...); a signature that does not hold; a timestamp further than webhook_tolerance from now (The callback is too old.); a signed body that is not a JaaS event (no eventType). Answer such a callback with a 4xx.

InvalidSignalException

omnimeet/direct's Signaling::send(): no room or no sender, a type other than hello, offer, answer, candidate, bye, a payload that is not JSON or is larger than 64 kB. Answer the page with a 400.

InvalidModelException

A model built with what it cannot hold, an \InvalidArgumentException too (a catch of it keeps working):

Message Thrown by
A meeting needs a key. new Meeting('')
A meeting cannot end before it starts. new Meeting() with endsAt before startsAt
A participant needs an identifier. new Participant('')
An engine access names its script and its file. new Access(AccessKind::ENGINE) without engine or script
A frame or a link needs an address. new Access(AccessKind::FRAME) or LINK without url

Proposer une modification docs/errors.md

Testing

Omnimeet's gateways call nothing over the network, so an application tests its code against the real ones: no mock client, no fixtures. There is no fake gateway and no conformance suite in the family.

The direct gateway in a test

InMemorySignalStore keeps the handshake in the test's process; the whole flow - open, join, the handshake, fetch, close - runs as it does between two browsers:

<?php // direct.php

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

use Omnimeet\Direct\DirectGatewayFactory;
use Omnimeet\Direct\Exception\InvalidSignalException;
use Omnimeet\Direct\InMemorySignalStore;
use Omnimeet\Direct\TurnCredentials;
use Omnimeet\Model\Meeting;
use Omnimeet\Model\Participant;

$factory = new DirectGatewayFactory(new InMemorySignalStore());
$gateway = $factory->create(['turn_secret' => 'coturn-static-auth-secret', 'turn_urls' => 'turn:turn.example.org:3478?transport=udp,turn:turn.example.org:3478?transport=tcp', 'turn_ttl' => 600]);

$meeting = $gateway->open(new Meeting('k4f9d2a7c1e8b3f6'));
$access = $gateway->join($meeting, Participant::host('u1'));
echo $access->kind->value, ' ', basename($access->script), ' until ', $access->expiresAt->format('H:i:s'), "\n";
echo json_encode($access->options['iceServers'][0], JSON_UNESCAPED_SLASHES), "\n";
$turn = TurnCredentials::fromOptions(['turn_secret' => 'coturn-static-auth-secret', 'turn_urls' => ['turn:turn.example.org:3478']]);
var_dump($turn->verify($access->options['iceServers'][0]['username'], $access->options['iceServers'][0]['credential']));

// What the application's endpoint does for each POST and GET
$signaling = $factory->signaling();
$signaling->send($meeting->reference, 'u2', 'hello');
$signaling->send($meeting->reference, 'u1', 'offer', '{"type":"offer","sdp":"v=0..."}');
echo json_encode($signaling->receive($meeting->reference, 'u2')), "\n";
$answer = $signaling->send($meeting->reference, 'u2', 'answer', '{"type":"answer","sdp":"v=0..."}');
var_dump($answer->connects());
$state = $gateway->fetch($meeting);
echo $state->status->value, ', present: ', implode(', ', array_map(fn ($p) => $p->id, $state->present)), "\n";
try {
    $signaling->send($meeting->reference, 'u2', 'renegotiate');
} catch (InvalidSignalException $e) {
    echo $e->getMessage(), "\n";
}
echo $gateway->close($meeting)->status->value, ', ', count($signaling->receive($meeting->reference, 'u1')), " message(s) left\n";
$ php direct.php
engine visio.js until 09:54:14
{"urls":["turn:turn.example.org:3478?transport=udp","turn:turn.example.org:3478?transport=tcp"],"username":"1793008454:u1","credential":"BV94caMIQhCOOyfH7BZkATh/pBw="}
bool(true)
[{"id":2,"type":"offer","payload":{"type":"offer","sdp":"v=0..."}}]
bool(true)
live, present: u2, u1
Unknown signal "renegotiate".
ended, 0 message(s) left

(run on 2026-10-26, PHP 8.4.27, no network; the credentials change with the time)

An application's own SignalStoreInterface (a table, a cache) is tested the same way, by giving it to the factory in place of the in-memory one.

The Jitsi gateway in a test

join() answers the frame's address and options, and the token signed on this side: a test reads them, and checks the token with Omnimeet\Jitsi\Jwt::verify($token, $secret) (HS256) or the public key (RS256). A JaaS callback is signed in the test with the endpoint's secret:

$t = (string) time();
$signature = base64_encode(hash_hmac('sha256', $t.'.'.$body, 'whsec_example', true));
$notification = $gateway->notify($body, ['X-Jaas-Signature' => 't='.$t.',v1='.$signature]);

A whole example and its output: omnimeet/jitsi.

A gateway of the test's own

A GatewayFactory whose actions answer from arrays stands in for a provider: the core's tests do it with Tests/StubFactory.php. Registered in a Registry (or as a service tagged omnimeet.gateway_factory) under the name the application uses, it replaces the real one.

In the browser

What the PHP side cannot show - two browsers connecting, the relay, a frame an instance refuses - is checked with the harness's demo (docker compose up, two tabs: harness) or two headless browsers with fake cameras, as the verified page says was done.

Proposer une modification docs/testing.md

The Docker harness

docker/ runs this package with every omnimeet/* gateway installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNIMEET_PLUGINS=../.. is set in docker/.env - with a console, a script in bare PHP, and a demo where two browser tabs meet.

cd docker && cp .env.dist .env       # a relay, a Jitsi instance, when you have them
docker compose run --rm omnimeet gateways
Command
gateways the gateways installed and configured, what their meetings are, what each does
open <gateway> <key> the meeting's reference on that gateway (--title, --starts, --ends)
join <gateway> <key> how a participant enters (JSON): --role host|guest, --id, --name, --locale; a token is shown decoded
handshake direct: a whole handshake between a host and a guest, message by message, then the room closed
notify <gateway> <file> a provider's callback read from a file, its signature checked: --signature "t=...,v1=..."
bare plain PHP: the registry built by hand, a meeting held on each gateway, what PHP loaded
test every package's tests
docker compose up the demo: http://localhost:8799/

The configured gateways are direct (nothing needed; TURN_SECRET and TURN_URLS for a relay) and jitsi (when JITSI_DOMAIN is set; JITSI_APP_ID and JITSI_APP_SECRET, or JITSI_PRIVATE_KEY and JITSI_KEY_ID, when the instance checks tokens; JAAS_WEBHOOK_SECRET).

$ docker compose run --rm omnimeet gateways
+---------+---------+-----------+--------------------+--------+-------------+------------+--------+-----------+------------+-----------------------+
| Gateway | Factory | Installed | Configured         | People | Third party | End to end | Tokens | Recording | Lets in by | Does                  |
+---------+---------+-----------+--------------------+--------+-------------+------------+--------+-----------+------------+-----------------------+
| direct  | direct  | yes       | yes                | 2      | no          | yes        | no     | no        | engine     | open join close fetch |
| jitsi   | jitsi   | yes       | needs JITSI_DOMAIN |        |             |            |        |           |            |                       |
+---------+---------+-----------+--------------------+--------+-------------+------------+--------+-----------+------------+-----------------------+

$ docker compose run --rm omnimeet handshake
Room handshake-3435a15e6532: the guest arrives first, the host offers, the guest answers.
       ICE servers given to each: none (a direct connection, or none)
  #1  guest  hello
       open, present: guest
       host   hears     #1 hello
  #2  host   offer
  #3  host   candidate
       guest  hears     #2 offer, #3 candidate
  #4  guest  answer    connects
  #5  guest  candidate
       host   hears     #4 answer, #5 candidate
       live, present: guest, host
  #6  guest  bye
       host   hears     #6 bye
       open, present: host
Closed: ended, 0 message(s) left.

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 gateway packages installed, asks each what its meetings are and what it does, holds a meeting on each (a gateway whose settings are not in the environment runs on example ones: nothing is called either way), 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, Doctrine, Twig):

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

  direct   Direct: 2 people, no third party, end to end; lets in by engine; does open join close fetch
  jitsi    Jitsi Meet: several people, through a third party, not end to end; lets in by frame, link; does open join (example settings)

direct, the meeting "bare-meeting-a-key-nobody-guesses":
  the host is let in by the direct engine (visio.js), 0 ICE server(s), the guest as "guest"
  the host heard: hello; the guest heard: offer, candidate
  the answer connects them: live (present: u2, u1)
  closed: ended, 0 message(s) left in the store

jitsi, the same meeting:
  its room: 124fa45aeff281624eb50bbf2ec46352 (the same when opened again: yes)
  the host is let in by a frame on https://meet.example.org/124fa45aeff281624eb50bbf2ec46352, driven by jitsi.js through https://meet.example.org/external_api.js
  a token for the room 124fa45aeff281624eb50bbf2ec46352, for Dr Claire Tilleul (u1), valid until after the meeting's end: yes

Loaded from Symfony: nothing
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig): none

bare --json prints the same whole, every class and file loaded: Tests/BareTest.php runs it in a process of its own and checks the list - nothing of Symfony at all is loaded, since no gateway calls anything.

The demo: a call between two tabs, in plain PHP

docker compose up        # http://localhost:8799/ (OMNIMEET_PORT)

docker/harness/public/index.php behind PHP's own web server - no framework. Pick a gateway, open the meeting as the host in one tab and as the guest in another (or another browser): the page opens the meeting, joins, renders the Access it is given, serves the gateway's script from its package and, for the direct gateway, relays the handshake into a file per room (Omnimeet\Harness\FileSignalStore, forty lines: what a SignalStoreInterface is). It is what an application does around a gateway, in small - except that it checks nobody: whoever has the address is in.

localhost is a secure context: the browser gives the camera without TLS.

The image is php:8.4-cli-alpine with Composer; the harness's packages live in the harness volume of the omnimeet-harness project. The gateways are cloned from GitHub as plain git repositories over HTTPS: no GitHub API, no ssh in the image.

Proposer une modification docs/harness.md

Verified, and not

What the tests prove

Run on 2026-10-26 on the workspace's checkouts (the core, omnimeet/direct, omnimeet/jitsi), PHP 8.4.27, PHPUnit 11.5.57, no network: 70 tests, 357 assertions, green.

  • The core: a meeting opened, joined, fetched and closed; a meeting not opened cannot be joined; a callback read once its signature holds; a gateway saying what it does not do; an action leaving its request unanswered is an error; an option missing said when the gateway is built (GatewayTest); gateways by name built once, nothing built before it is asked for, a gateway built afresh with overrides (RegistryTest); the models' rules (ModelTest); the bundle building the configured gateways, injectable by name, and giving a factory what its constructor asks of the application (Bridge/OmnimeetBundleTest).
  • In plain PHP (BareTest, bin/bare --json): the registry built by hand, a meeting held from browser to browser, a frame and its token made, nothing of Symfony loaded; the bundle does load the framework.
  • omnimeet/direct: two people, end to end, no third party; the engine with the participant's own relay credentials; without a relay, no ICE server; the room read from its handshake and purged on close; the handshake's rules (order, pages, the five messages, JSON, size).
  • omnimeet/jitsi: an open instance; what the documentation does not show not supported; a token per participant (HS256, RS256), its audience, issuer, subject and lifetime; JaaS's claims, rooms and script; room_secret; the configuration overrides; embed: false; misconfigurations said when first asked; JaaS callbacks of every event, refused unsigned, with another secret, changed on the way, replayed an hour later, or not an event; the documentation's own signature example.

Run for this manual

On 2026-10-26, PHP 8.4.27, no network, the workspace's checkouts: the script of Installation and bin/bare (harness), the bundle's configuration tree and services (Symfony), the direct gateway's flow of Testing, and the providers' examples. Their output is pasted as it came.

In browsers

As the gateways' manuals state: a real call between two headless Chromes with fake cameras on 2026-10-05, through omnibase/office's room - direct (and through a local coturn with ?relay=1), and on a public open Jitsi instance (meet.evolix.org); two public instances seen refusing to be framed or waiting for a signed-in moderator.

What is not verified

  • Jitsi tokens against a real instance with token authentication, Jitsi as a Service and its webhooks: written and tested against the documentation only.
  • endConference from a host's page: sent, what the instance then does not checked.
  • embed: false in a browser.
  • Laravel: no bridge; the service provider of Laravel is a sketch, not a tested package.
  • No conformance suite exists for the family yet.

Proposer une modification docs/verified.md