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
- Installation: a first meeting in plain PHP, what the application does around a gateway.
- Symfony and Laravel.
- Models:
Meeting,Participant,Access,Capabilities,Status,Notification. - Gateways: the contract, the factory and the registry, rendering an
Access, the state of a meeting, callbacks, writing a gateway. - Configuration reference: the registry, the bundle, every gateway's options.
- Errors.
- Testing.
- The Docker harness: the console, bare PHP, the demo between two tabs.
- Verified, and not.
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/omnimeetrequires nothing else, nor doesomnimeet/direct. omnimeet/jitsisuggestsext-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:
- Decide who may enter, and when - before asking. A gateway checks nobody.
- Open the meeting once and keep its reference (
$meeting->reference) with its own record of the meeting, and the gateway's name. - On the participant's page, join - each time: an
Accessis 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. - 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\OmnimeetBundleregisters the factories, builds the registry fromconfig/packages/omnimeet.yamland 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/officeis 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
Registryonce, 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 = []):
- fills an
Omnimeet\Config(anArrayObject) with the options given, then the factory's defaults (populateConfig(), throughConfig::defaults()) under them; - checks
omnimeet.required_options: one missing or empty (null,'',[]) is anInvalidConfigExceptionnaming them all (The "jitsi" gateway needs: domain.); - builds the client from
omnimeet.apiand the capabilities fromomnimeet.capabilities(each a value or a closure of theConfig); noCapabilitiesis anInvalidConfigException; - collects every
omnimeet.action.<name>(a closure of theConfigis called; one that gives anything but an action -null- is left out), hands the client to the actions implementingApiAwareInterface(ApiAwareTraitchecks its class), and returns aGateway.
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 anENGINE. - A script boots on its own root,
[data-omnimeet-<engine>](data-omnimeet-direct,data-omnimeet-jitsi), and takes$access->optionsfrom it: each package's documentation gives its markup. A script that drives a frame puts it in[data-omnimeet-frame], is toldomnimeet:join,omnimeet:leave,omnimeet:end(events on its root) and says where it stands withomnimeet: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->originslists 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'sPermissions-Policy(camera=(self "https://meet.example.org")) and its Content-Security-Policy (frame-src,script-src);$access->allow()is the frame'sallowattribute. - Ask again each time. An
Accesscarries 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.
endConferencefrom a host's page: sent, what the instance then does not checked.embed: falsein 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.