Omnifood is one PHP contract for the restaurant platforms: the delivery orders of Uber Eats, Deliveroo and Just Eat, the table reservations of TheFork and Zenchef. A site holds its menu, its hours and its bookings once; each platform's package turns them into that platform's calls, and turns what the platform sends back - orders, reservations, webhooks - into the same models.
$notification = $ubereats->notify($body, $headers); // a webhook, checked and read
$order = $notification->order ?? $ubereats->order($notification->reference);
$ubereats->accept($order->reference, new \DateTimeImmutable('+20 minutes'));
$ubereats->ready($order->reference); // the courier may come
$deliveroo->pushMenu($menu); // checked first, then sent whole
$deliveroo->setAvailability('gyoza', false); // out of stock
$justeat->pause(new \DateTimeImmutable('+30 minutes')); // no new order for half an hour
$thefork->reservations(new \DateTimeImmutable('today'), new \DateTimeImmutable('tomorrow'));
The platforms do different things, so there is no one big interface: a platform implements the
capabilities it offers - OrdersInterface, MenuInterface, StoreInterface,
ReservationsInterface, NotifiableInterface - and Auth\RefreshableInterface or
Auth\OAuthInterface when it holds a token. What a platform offers but its public documentation
does not show how to call throws NotSupportedException: nothing is guessed. The design is
Omnibus's: a platform's factory builds its client from options, a registry holds the
restaurant's platforms by name.
The core needs no framework: it requires symfony/http-client-contracts and nothing else. Each
platform package requires symfony/http-client, a library that stands alone. A Symfony bundle is
included for applications that want the wiring done (Symfony).
Published as omnisense/omnifood, formerly glitchr/omnifood and omniscient/omnifood: the
package replaces both names, so a composer.json that still requires one of them resolves to
this package and never installs both. The namespace is Omnifood\; the platforms keep the
omnifood/ vendor and the Omnifood\<Platform>\ namespace.
When to use it
- A restaurant, a dark kitchen or a till that takes orders from more than one delivery platform and wants one order screen, one menu and one "pause" button for all of them.
- A site that keeps its own table bookings and wants those of TheFork in the same list, or records a phone booking on TheFork so that the table is not sold twice.
- A back office that checks a menu against each platform's rules before anything is sent (the validator).
It is not an aggregator service: there is no account of its own, no queue and no storage. Each platform is called with the restaurant's own partner keys, and keeping the orders, the tokens and the webhook ids handled is the application's job.
Platforms
Five packages. The interfaces are what each platform's class implements (probed in the
harness on 2026-10-26); "refused" means the method exists, as the interface
requires, and throws NotSupportedException.
| Package | Factory name | Orders | Menu | Store | Reservations | Webhooks | Tokens |
|---|---|---|---|---|---|---|---|
omnifood/ubereats |
ubereats |
yes | yes | yes | - | HMAC of the body | refreshable, OAuth |
omnifood/deliveroo |
deliveroo |
yes, cancel() refused |
yes | yes | - | HMAC of guid and body | refreshable |
omnifood/justeat |
justeat |
pushed only: order(), orders(), ready(), cancel() refused |
yes | status() refused |
- | key and/or HMAC | - |
omnifood/thefork |
thefork |
- | - | - | yes, setStatus() cancels only |
token in the URL | refreshable |
omnifood/zenchef |
zenchef |
- | - | - | every call refused | refused | - |
What each platform takes and imposes - the order types, the delay to accept, how deep modifiers
nest, name lengths - is its capabilities() (Concepts), which needs
no key. Each platform's own manual lists its options, credentials and behaviour.
No platform gives API keys without a partner agreement: every package is tested against recorded answers written from the platform's public documentation, none was called live (Verified, and not).
How this manual is organised
- Installation: requirements, a first script in plain PHP, a platform's API.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: platforms and their capabilities, factories, the registry, every model.
- Usage: orders, the menu, the store, reservations, webhooks.
- Configuration reference: the registry, keys typed in a back office, tokens and OAuth, every platform's options.
- Errors: the exceptions and when each is thrown.
- Testing: testing an application that uses Omnifood.
- The Docker harness: every platform installed, a console to call them.
- Writing a platform.
- Verified, and not.
Propose a change docs/index.md
Installation
composer require omnisense/omnifood
composer require omnifood/ubereats omnifood/deliveroo omnifood/justeat omnifood/thefork omnifood/zenchef
Take only the platforms the restaurant is on.
Requirements
- PHP 8.2 or later.
omnisense/omnifoodrequiressymfony/http-client-contracts(^3.0) and nothing else.- Each platform package requires
omnisense/omnifood(1.x) andsymfony/http-client(^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework. ext-json(always present since PHP 8.0). The menu validator and Deliveroo's menu check callmb_strlen():omnisense/omnifoodrequiresext-mbstring.
Omnifood 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
A whole script: the registry built by hand, an Uber Eats order read. No platform gives keys without a partner agreement, so the HTTP client here answers as Uber would, from a recorded answer.
<?php // bare.php
require __DIR__.'/vendor/autoload.php';
use Omnifood\Registry;
use Omnifood\UberEats\UberEatsPlatformFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
// Uber's answers, recorded: no platform gives keys without a partner agreement.
$http = new MockHttpClient(static fn (string $method, string $url) => new MockResponse(str_contains($url, '/oauth/')
? '{"access_token": "recorded", "expires_in": 3600}'
: '{"order": {"id": "o-1", "display_id": "A1B2C", "state": "OFFERED", "fulfillment_type": "DELIVERY_BY_UBER",
"carts": [{"items": [{"id": "ramen", "title": "Shoyu ramen", "quantity": {"amount": 2}}]}]}}'));
$registry = new Registry([new UberEatsPlatformFactory($http)], [
'ubereats' => ['factory' => 'ubereats', 'options' => ['client_id' => 'id', 'client_secret' => 'secret', 'store_id' => 'store-1']],
]);
$ubereats = $registry->get('ubereats');
echo 'Uber Eats: accept within ', $ubereats->capabilities()->acceptanceDelay, " s\n";
$order = $ubereats->order('o-1');
foreach ($order->lines as $line) {
echo '#', $order->displayId, ' ', $order->type->value, ' ', $order->status->value, ': ', $line->quantity, ' x ', $line->name, "\n";
}
$ php bare.php
Uber Eats: accept within 690 s
#A1B2C delivery new: 2 x Shoyu ramen
(run on 2026-10-26 in the harness, PHP 8.4, every package from its checkout)
With the restaurant's keys, the client is the real one and the script calls Uber:
$http = Symfony\Component\HttpClient\HttpClient::create();
$options = [
'client_id' => getenv('UBEREATS_CLIENT_ID') ?: null,
'client_secret' => getenv('UBEREATS_CLIENT_SECRET') ?: null,
'store_id' => getenv('UBEREATS_STORE_ID') ?: null,
];
The pieces:
- a factory per platform package (
UberEatsPlatformFactory,DeliverooPlatformFactory,JustEatPlatformFactory,TheForkPlatformFactory,ZenchefPlatformFactory), which takes the HTTP client to call with - the application's, aMockHttpClientin a test; with none given the platform makes its own (HttpClient::create()); - the registry, built from the factories and the platforms' options, by name
(Configuration):
get()builds a platform once,create()builds one afresh with other options; - the platforms it gives, each as what it does:
OrdersInterface,MenuInterface,StoreInterface,ReservationsInterface,NotifiableInterface(Concepts).
One platform needs no registry: (new UberEatsPlatformFactory($http))->create($options).
No class of a framework is loaded on the way: Tests/BareTest.php checks it in a process of its
own, and so does docker compose run --rm omnifood bare (harness).
Building a platform calls nothing
A factory checks nothing and calls nothing: no platform declares a required option, so
create() succeeds with no key at all. A key is asked for when a call needs it, and its absence is
an InvalidConfigException naming it then:
The "ubereats" platform needs: client_id, client_secret. (the first call that needs a token)
The "ubereats" platform needs: store_id. (orders(), the menu, the store)
The "justeat" platform needs: api_key.
So capabilities() and the menu validator work before an account exists,
and a site boots with its platforms declared and no keys yet. A token is asked for on the first
call that needs one, not when the platform is built.
In a framework
- Symfony:
Omnifood\Bridge\Symfony\OmnifoodBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnifood.yamland makes each platform injectable by its name, as what it does: Symfony. Its components (symfony/config,symfony/dependency-injection,symfony/http-kernel) are not required by this package: a Symfony application has them. - Laravel: there is no Omnifood provider in omnibridge/laravel; a service provider of the application builds the registry once: Laravel.
- Any other: build the
Registryonce, where the framework builds its services (a container definition, a factory), as the script above does.
Propose a change docs/installation.md
Symfony
Omnifood runs without a framework (Installation); in a Symfony application
its bundle does the wiring. The bundle's components - symfony/config,
symfony/dependency-injection, symfony/http-kernel (6.4, 7 or 8) - are not required by
omnisense/omnifood: the application has them, and nothing of them is loaded outside Symfony.
Registering the bundle
There is no Flex recipe: add the bundle by hand.
// config/bundles.php
return [
// ...
Omnifood\Bridge\Symfony\OmnifoodBundle::class => ['all' => true],
];
Configuration
# config/packages/omnifood.yaml
omnifood:
platforms: # by name: a factory and its options
ubereats:
factory: ubereats
options:
client_id: '%env(default::UBEREATS_CLIENT_ID)%'
client_secret: '%env(default::UBEREATS_CLIENT_SECRET)%'
store_id: '%env(default::UBEREATS_STORE_ID)%'
thefork:
factory: thefork
options:
client_id: '%env(default::THEFORK_CLIENT_ID)%'
client_secret: '%env(default::THEFORK_CLIENT_SECRET)%'
restaurant_id: '%env(default::THEFORK_RESTAURANT_ID)%'
webhook_token: '%env(default::THEFORK_WEBHOOK_TOKEN)%'
# .env.local, or bin/console secrets:set
UBEREATS_CLIENT_ID=...
UBEREATS_CLIENT_SECRET=...
%env(default::NAME)% resolves to null when the variable is not set; an option left null or
'' is as if not given (Configuration), so the
application boots before its accounts exist.
The whole tree, from the bundle's configure():
| Key | Type | Default | |
|---|---|---|---|
omnifood.platforms |
map, keyed by the platform's name | {} |
the restaurant's platforms |
omnifood.platforms.<name>.factory |
string, required, not empty | - | a factory's name: ubereats, deliveroo, justeat, thefork, zenchef (platforms), or an application's own factory's getName() |
omnifood.platforms.<name>.options |
any value (a map) | [] |
handed as they are to the factory's create(): the platform's options (Configuration) |
The options are not validated when the container compiles - the tree accepts any value - and no
platform declares a required option: a key missing shows when a call needs it, as an
InvalidConfigException naming it. Two stores on the same platform are two entries with the
same factory.
Services
The bundle knows the five platform packages (OmnifoodBundle::FACTORIES) and registers the
factory of each one installed, built on the application's http_client service (or with none,
when the application has no HTTP client service: the platform then makes its own).
| Service id | Class | Autowiring | |
|---|---|---|---|
Omnifood\Registry |
Registry |
Registry $registry |
every configured platform by name (Concepts). Public. |
Omnifood\Validator |
Validator |
Validator $validator |
a menu checked against a platform's capabilities |
omnifood.platform.<name> |
PlatformInterface |
OrdersInterface $<name>, MenuInterface $<name>, StoreInterface $<name>, ReservationsInterface $<name>, NotifiableInterface $<name>, PlatformInterface $<name> |
one platform, built by Registry::get('<name>') the first time it is injected |
Omnifood\<Platform>\<Platform>PlatformFactory |
the platform's factory | by its class | tagged omnifood.platform_factory |
use Omnifood\OrdersInterface;
use Omnifood\ReservationsInterface;
final class KitchenController
{
public function __construct(
private readonly OrdersInterface $ubereats, // the "ubereats" platform, as what takes orders
private readonly ReservationsInterface $thefork, // the "thefork" platform
) {
}
}
The argument's name selects the platform (Symfony's named autowiring aliases,
registerAliasForArgument()), the type says what it is used as. Every platform gets the six
aliases, whatever it implements: a platform injected as something it does not do fails where it
is injected, a TypeError when the service is built (ReservationsInterface $ubereats). Inject
PlatformInterface, or the Registry and its orders() / reservations() lists, when in doubt.
Nothing is built when the container compiles: a platform is built the first time it is asked
for. Keys typed in a back office rather than set in the environment:
$registry->create('ubereats', ['client_secret' => $stored]), a platform built afresh with those
options over the configured ones and not kept by the registry.
An application's own platform
A class of the application that implements Omnifood\PlatformFactoryInterface (usually by
extending Omnifood\PlatformFactory) is autoconfigured: tagged omnifood.platform_factory, it
joins the registry, and its getName() can be named as a factory:
omnifood:
platforms:
till:
factory: my_till # App\Food\MyTillPlatformFactory::getName()
A webhook
use Omnifood\Exception\InvalidSignatureException;
use Omnifood\NotifiableInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/webhooks/ubereats', methods: ['POST'])]
public function ubereats(Request $request, NotifiableInterface $ubereats, MessageBusInterface $bus): Response
{
try {
$notification = $ubereats->notify($request->getContent(), $request->headers->all());
} catch (InvalidSignatureException) {
return new Response('', 401);
}
$bus->dispatch(new HandleNotification($notification->channel->value, $notification->id, $notification->reference));
return new Response('', 200);
}
HandleNotification is the application's message. Answer fast and work later (Messenger): see
webhooks. TheFork's token travels in the URL: pass it among the headers
($request->headers->all() + ['token' => $request->query->get('token')], webhooks).
The bundle registers no command, event, Twig function or route: the platforms are services, used from the application's controllers, message handlers and commands.
Propose a change docs/symfony.md
Laravel
omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omnifood provider: none is
needed. The registry is plain PHP; a service provider of the application builds it once, from a
config file, with a symfony/http-client client (every platform package requires that library;
Laravel's own HTTP client is Guzzle's, which the platforms do not take).
// config/omnifood.php
return [
'platforms' => [
'ubereats' => ['factory' => 'ubereats', 'options' => [
'client_id' => env('UBEREATS_CLIENT_ID'),
'client_secret' => env('UBEREATS_CLIENT_SECRET'),
'store_id' => env('UBEREATS_STORE_ID'),
]],
'thefork' => ['factory' => 'thefork', 'options' => [
'client_id' => env('THEFORK_CLIENT_ID'),
'client_secret' => env('THEFORK_CLIENT_SECRET'),
'restaurant_id' => env('THEFORK_RESTAURANT_ID'),
'webhook_token' => env('THEFORK_WEBHOOK_TOKEN'),
]],
],
];
// app/Providers/OmnifoodServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Omnifood\Registry;
use Omnifood\TheFork\TheForkPlatformFactory;
use Omnifood\UberEats\UberEatsPlatformFactory;
use Omnifood\Validator;
use Symfony\Component\HttpClient\HttpClient;
final class OmnifoodServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Registry::class, function (): Registry {
$http = HttpClient::create();
return new Registry([
new UberEatsPlatformFactory($http),
new TheForkPlatformFactory($http),
], config('omnifood.platforms'));
});
$this->app->singleton(Validator::class);
}
}
Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the
registry where it is needed:
use Illuminate\Http\Request;
use Omnifood\Exception\InvalidSignatureException;
use Omnifood\Registry;
final class UberEatsWebhookController
{
public function __invoke(Request $request, Registry $registry)
{
try {
$notification = $registry->get('ubereats')->notify($request->getContent(), $request->headers->all());
} catch (InvalidSignatureException) {
return response('', 401);
}
HandleNotification::dispatch($notification->channel->value, $notification->id, $notification->reference); // the application's job
return response('', 200);
}
}
env() gives null for a variable not set, which a factory reads as "not given". List in the
provider every platform factory the application uses: unlike the Symfony bundle, a hand-built
registry knows only the factories it is given. Configuration caching (php artisan config:cache)
keeps working: the options are plain arrays read through config(). Exclude the webhook routes
from CSRF verification, as for any webhook. This page was written from the registry's API; it was
not run in a Laravel application.
Propose a change docs/laravel.md
Concepts
Platform and its capabilities
Omnifood\PlatformInterface is one platform's client, for one restaurant:
| Method | Returns |
|---|---|
getName(): string |
the name it is configured as: ubereats, deliveroo, justeat, thefork, zenchef |
getChannel(): Channel |
the platform, as the channel of every order, reservation and notification it returns |
capabilities(): Capabilities |
what the platform takes and imposes (below); needs no key |
A platform implements, beside it, the capability interfaces of what it offers. Each extends
PlatformInterface:
| Interface | Methods | Usage |
|---|---|---|
OrdersInterface |
order(string $ref): Order, orders(?\DateTimeImmutable $since = null): list<Order>, accept(string $ref, ?\DateTimeImmutable $readyAt = null): void, deny(string $ref, DenyReason $reason, ?string $note = null): void, ready(string $ref): void, cancel(string $ref, DenyReason $reason, ?string $note = null): void |
Orders |
MenuInterface |
pushMenu(Menu $menu): void, setAvailability(string $itemRef, bool $available, ?\DateTimeImmutable $until = null): void |
Menu |
StoreInterface |
status(): StoreStatus, pause(?\DateTimeImmutable $until = null, ?string $reason = null): void, resume(): void, setHours(Hours $hours): void |
Store |
ReservationsInterface |
reservations(\DateTimeImmutable $from, \DateTimeImmutable $to): list<Reservation>, reservation(string $ref): Reservation, createReservation(Reservation $r): Reservation, updateReservation(Reservation $r): Reservation, cancelReservation(string $ref, ?string $reason = null): void, setStatus(string $ref, ReservationStatus $status): void, pushAvailability(list<Slot> $slots): void |
Reservations |
NotifiableInterface |
notify(string $body, array $headers): Notification |
Webhooks |
And for the platforms that hold a token, in Omnifood\Auth (these do not extend
PlatformInterface):
| Interface | Methods | Implemented by |
|---|---|---|
RefreshableInterface |
token(): ?Token (the one held, null before the first call), refresh(): Token (a new one, asked for now) |
Uber Eats, Deliveroo, TheFork |
OAuthInterface |
authorizationUrl(string $redirectUri, string $state, list<string> $scopes = []): string, exchange(string $code, string $redirectUri): Token |
Uber Eats |
A platform asks for its token itself when a call needs one; token() and refresh() are for a
site that keeps the token between processes (Configuration).
$ref is always the platform's own id for the order or the reservation. A method a platform
must have, as its interface requires, but cannot do - the platform does not offer it, or does not
document how - throws NotSupportedException before anything is sent. The method names that
write a reservation carry the word (createReservation(), cancelReservation()) so that one
platform may implement both OrdersInterface and ReservationsInterface:
OrdersInterface::cancel() is an order's.
Factory and Config
Omnifood\PlatformFactoryInterface builds a platform from options:
| Method | |
|---|---|
getName(): string |
the name platforms are configured with (ubereats...) |
create(array $options = []): PlatformInterface |
the platform, built |
Every platform package extends Omnifood\PlatformFactory, which takes an optional
Symfony\Contracts\HttpClient\HttpClientInterface in its constructor (none: the platform calls
HttpClient::create() itself) and does the work in create():
createConfig($options)- the options with everynulland''removed, in aConfig(anArrayObject); then the factory'spopulate()sets its name, its required options and the default of each option not given (Config::defaults()sets only keys not set yet);- the keys listed in
omnifood.required_optionsare checked: one missing,''or[]is anInvalidConfigExceptionlisting every missing key (The "x" platform needs: a, b.); build($config)constructs the platform.
getName() is createConfig()['omnifood.factory_name']. The reserved keys:
| Key | |
|---|---|
omnifood.factory_name |
the platform's name |
omnifood.required_options |
list of option names that must be filled; [] for the five packages, which check a key when a call needs it |
Config::get($key, $default) reads a key, Config::validateNotEmpty($keys) is step 2.
Registry
Omnifood\Registry holds the restaurant's platforms by name:
$registry = new Registry(
[new UberEatsPlatformFactory($http), new TheForkPlatformFactory($http)], // iterable of factories, keyed by getName()
[
'ubereats' => ['factory' => 'ubereats', 'options' => [/* ... */]],
'thefork' => ['factory' => 'thefork', 'options' => [/* ... */]],
],
);
| Method | Returns |
|---|---|
get(string $name): PlatformInterface |
the platform, built the first time and kept |
create(string $name, array $overrides = []): PlatformInterface |
a platform built afresh, its configured options with $overrides over them (array_replace); not kept |
has(string $name): bool |
whether the name is configured (nothing is built) |
options(string $name): array |
its configured options ([] for an unknown name) |
names(): list<string> |
the configured names, in configuration order |
all(): array<string, PlatformInterface> |
every configured platform, built |
orders(), menus(), stores(), reservations(), notifiables() |
the configured platforms that implement OrdersInterface, MenuInterface, StoreInterface, ReservationsInterface, NotifiableInterface (built), as lists |
factories(): list<string> |
the names of the factories given |
get() and create() throw InvalidConfigException for a name not configured
(No "nope" platform; configured: shop.) and for a factory not given
(No "deliveroo" factory for the "shop" platform; installed: ubereats.). The lists by capability
call all(), so they build every configured platform; a platform that implements an interface
and refuses some of its methods (Just Eat's orders()) is in the list all the same.
Channel
Omnifood\Channel (string enum) is where an order or a reservation comes from:
| Case | Value | label() |
|---|---|---|
UBEREATS |
ubereats |
Uber Eats |
DELIVEROO |
deliveroo |
Deliveroo |
JUSTEAT |
justeat |
Just Eat |
THEFORK |
thefork |
TheFork |
ZENCHEF |
zenchef |
Zenchef |
DOORDASH |
doordash |
DoorDash |
WOLT |
wolt |
Wolt |
GLOVO |
glovo |
Glovo |
OPENTABLE |
opentable |
OpenTable |
SEVENROOMS |
sevenrooms |
SevenRooms |
DIRECT |
direct |
Direct (the restaurant's own site, the phone, the walk-in) |
DoorDash, Wolt, Glovo, OpenTable and SevenRooms have no package yet: the cases let an application keep its own orders and bookings from them in the same models.
Capabilities
Omnifood\Model\Capabilities (readonly) is what a platform takes and imposes, for a back office
to show and the validator to check a menu against. What the platform does
not say is null (no limit known).
| Property | Type | Default | |
|---|---|---|---|
orderTypes |
list<OrderType> |
[] |
the order types it takes; takes(OrderType) asks |
acceptanceDelay |
?int |
null |
seconds the restaurant has to accept a new order before the platform cancels it |
readyAt |
bool |
false |
whether accept() sends when the order will be ready |
modifierDepth |
int |
1 |
how deep modifiers nest: 0 none, 1 an item's groups, 2 a modifier's own groups |
allergens |
bool |
false |
whether the menu carries the EU-14 allergens |
photos |
bool |
false |
whether items carry a photo |
vatRates |
bool |
false |
whether each item carries its VAT rate |
nameMaxLength |
?int |
null |
the longest name, in characters |
descriptionMaxLength |
?int |
null |
the longest description |
pauseUntil |
bool |
false |
whether pause() sends an end |
deposits |
bool |
false |
whether a reservation may carry a deposit or a card guarantee |
availabilityPush |
bool |
false |
whether the restaurant can push its free tables (pushAvailability()) |
currencies |
list<string> |
[] |
ISO 4217 codes accepted; [] any |
modifiersAreItems |
bool |
false |
whether a modifier is a menu item of its own on the platform (its reference shares the items' space) |
What each platform answers (probed on 2026-10-26):
| Uber Eats | Deliveroo | Just Eat | TheFork | Zenchef | |
|---|---|---|---|---|---|
orderTypes |
delivery, pickup, dine_in | delivery, pickup, dine_in | delivery, pickup | - | - |
acceptanceDelay |
690 | 600 | null |
null |
null |
readyAt |
yes | no | no | no | no |
modifierDepth |
2 | 2 | 2 | 1 | 1 |
allergens, photos |
yes | yes | yes | no | no |
vatRates |
yes | yes | no (a VAT category) | no | no |
nameMaxLength / descriptionMaxLength |
- | 120 / 500 | - | - | - |
pauseUntil |
yes | no | yes | no | no |
availabilityPush |
no | no | no | yes | no |
modifiersAreItems |
yes | yes | no | no | no |
Money
Omnifood\Model\Money (readonly): an int $amount in the currency's minor units and a
string $currency, upper-cased by the constructor. Money::of(1250, 'EUR') is 12.50 EUR.
| Method | |
|---|---|
of(int $amount, string $currency): self |
the constructor, static |
equals(Money $other): bool |
same amount and currency |
decimal(): string |
in major units, as a decimal API wants it: "12.50"; "1250" for JPY, "1.250" for KWD |
fromDecimal(string|float $decimal, string $currency): self |
from a decimal, rounded to minor units |
minorDigits(string $currency): int |
0 for BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, MGA, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF; 3 for BHD, IQD, JOD, KWD, LYD, OMR, TND; 2 otherwise |
__toString() |
"12.50 EUR" |
Orders
Omnifood\Model\Order (readonly), the same shape for every platform. What the platform does not
tell is null; $raw keeps the platform's answer.
| Property | Type | |
|---|---|---|
channel |
Channel |
the platform |
reference |
string |
the platform's id: what every call on the order takes |
displayId |
?string |
the short number the courier and the customer say (A1B2C, #42) |
type |
OrderType |
how it leaves |
status |
OrderStatus |
where it stands |
lines |
list<Line> |
what was ordered |
customer |
?Customer |
who ordered |
total |
?Money |
what the customer paid |
subtotal |
?Money |
the items, before fees and discounts |
discount |
?Money |
discounts and promotions, as a positive amount |
fees |
?Money |
delivery, service and bag fees |
tax |
?Money |
the tax, where the platform says |
placedAt |
?\DateTimeImmutable |
when it was placed |
pickupAt |
?\DateTimeImmutable |
when it should be ready (collected by the courier or the customer) |
deliverAt |
?\DateTimeImmutable |
when it should reach the customer, for a delivery |
courier |
?Courier |
who collects it |
note |
?string |
the customer's note for the whole order (allergies included where the platform has no field of its own) |
store |
?string |
the store (restaurant) id on the platform |
cutlery |
?bool |
cutlery asked for; no package fills it yet |
raw |
array |
the platform's answer |
count(): int is the number of items, modifiers not counted (the sum of the lines' quantities).
Line: name (string), quantity (int), unitPrice and total (?Money, the total with the
modifiers, as the platform counts it), posRef (the restaurant's reference, Menu\Item::$ref,
when the platform carries it back), platformRef (the platform's id for the line or the item),
modifiers (list<Modifier>), note (the customer's words for this line).
Modifier (an order's): name, quantity (default 1), price (?Money, of one, on top of the
item's), posRef, group (the modifier group's name, when the platform says), modifiers
(nested choices).
Customer: name, phone, phoneCode (an extension to dial after the platform's masked
number), email, address (one line, when the restaurant delivers itself) - all ?string.
Courier: name, phone, status (the platform's own word: ARRIVED_AT_PICKUP...),
arrivesAt (?\DateTimeImmutable), vehicle.
OrderType: DELIVERY (delivery, by the platform's courier or the restaurant's own), PICKUP
(pickup, collected at the counter), DINE_IN (dine_in).
OrderStatus:
| Case | Value | |
|---|---|---|
NEW |
new |
sent to the restaurant, waiting for its answer |
ACCEPTED |
accepted |
|
READY |
ready |
ready for the courier or the customer |
PICKED_UP |
picked_up |
collected: on its way, or in the customer's hands |
DELIVERED |
delivered |
|
DENIED |
denied |
refused by the restaurant before it was accepted |
CANCELLED |
cancelled |
|
UNKNOWN |
unknown |
the platform's word is not one of these |
isFinal() is true for DELIVERED, DENIED and CANCELLED.
DenyReason, why the restaurant refuses or cancels, mapped by each platform to its own codes
(Orders): ITEM_UNAVAILABLE, CLOSED, TOO_BUSY, CUSTOMER_REQUEST,
ADDRESS, PRICING, SPECIAL_INSTRUCTIONS, TECHNICAL, OTHER (values in snake_case:
item_unavailable...).
Menu
The menu as a site holds it, in Omnifood\Model\Menu (all readonly). Every ref is the
restaurant's own (its till's): the platforms carry it back on the orders.
Menu: name (string), categories (list<Category>), currency (default EUR), hours
(?Hours, when it is served; null: whenever the store is open), ref (?string),
description (?string). items() lists every item, in the order of the categories.
Category: ref, name, items (list<Item>), description.
Item, a dish or a drink:
| Property | Type | Default | |
|---|---|---|---|
ref |
string |
- | the restaurant's reference, the one setAvailability() and the orders take |
name |
string |
- | |
price |
Money |
- | |
description |
?string |
null |
|
vatRate |
?float |
null |
in percent: 10.0, 5.5 |
allergens |
list<Allergen> |
[] |
|
photo |
?string |
null |
a public URL the platform fetches |
modifierGroups |
list<ModifierGroup> |
[] |
|
available |
bool |
true |
|
labels |
list<string> |
[] |
what the model has no field for: vegetarian, vegan, gluten_free, alcohol, no_allergens, a platform's own allergen codes (Menu) |
ModifierGroup, a choice on an item: ref, name, modifiers (list<Modifier>), min
(default 0), max (?int, null: as many as there are). isRequired() is min > 0.
Modifier (a menu's), one option of a group: ref, name, price (?Money, on top of the
item's; null or zero: free), allergens, available (default true), modifierGroups (groups
of its own: a set menu's main course, and its cooking), vatRate (?float; null: the item's).
Allergen, the fourteen of EU Regulation 1169/2011, Annex II: GLUTEN (Cereals containing
gluten), CRUSTACEANS, EGGS, FISH, PEANUTS, SOYBEANS, MILK, NUTS, CELERY, MUSTARD,
SESAME (Sesame seeds), SULPHITES (Sulphur dioxide and sulphites), LUPIN, MOLLUSCS; values
in lower case, label() the regulation's name.
Violation: path (categories[0].items[2].name) and message, __toString() both
(path: message).
Hours
Omnifood\Model\Hours (readonly): opening hours.
new Hours(
[1 => [['12:00', '14:30'], ['19:00', '22:30']], 5 => [['19:00', '01:00']]], // day of the week => ranges
['2026-12-25' => [], '2026-12-31' => [['19:00', '02:00']]], // dates apart
);
week: for each day, 1 Monday to 7 Sunday, its ranges[from, to]inHH:MM(00:00 to 23:59), sorted by their start. A day not given, or with no range, is closed. A range may end after midnight (['19:00', '01:00']).exceptions:Y-m-d=> ranges;[]is closed that day.day(int $day): that day of the week's ranges.on(\DateTimeInterface $date): that date's exception if it has one, else its day of the week's.isOpenAt(\DateTimeImmutable $at): whether$atfalls in one of its date's ranges; a range past midnight is counted on the day it starts (Friday's 19:00-01:00 covers Saturday 00:30).
The constructor throws \InvalidArgumentException for a day outside 1-7 (Day 8: 1 (Monday) to 7 (Sunday).), a time that is not HH:MM (Day 1: "24:00" is not a time, HH:MM.), an empty range
(Day 1: a range from 12:00 to 12:00 is empty.) or an exception that is not a date.
The store
StoreStatus (readonly): state (StoreState), until (?\DateTimeImmutable, when a pause
ends, when the platform says), reason (?string), raw. isOpen() is state === OPEN.
StoreState: OPEN (taking orders), PAUSED (within its hours, taking none for a while),
CLOSED (outside its hours), UNKNOWN.
Reservations
Reservation (readonly), a table booked:
| Property | Type | Default | |
|---|---|---|---|
date |
\DateTimeImmutable |
- | when the guests come, in the restaurant's time zone |
covers |
int |
- | how many |
guest |
Guest |
- | for whom |
status |
ReservationStatus |
CONFIRMED |
|
reference |
?string |
null |
the platform's id; null for a reservation not recorded yet |
channel |
?Channel |
null |
|
notes |
?string |
null |
the guest's request ("a high chair") |
allergies |
?string |
null |
|
table |
?string |
null |
the table asked for or given |
duration |
?int |
null |
minutes the table is held |
deposit |
?Money |
null |
a deposit, a prepayment, a card imprint's guarantee |
source |
?string |
null |
where it came from inside the platform: widget, marketplace... |
createdAt |
?\DateTimeImmutable |
null |
|
restaurant |
?string |
null |
the restaurant's id on the platform |
raw |
array |
[] |
the platform's answer; also extra fields a platform reads when writing (Reservations) |
with(...$changes) gives the same reservation with named fields replaced:
$reservation->with(covers: 5).
Guest: firstName, lastName, email, phone, locale (fr, en_GB), id (the
platform's id for the guest) - all ?string; name() is first and last names joined.
ReservationStatus:
| Case | Value | |
|---|---|---|
REQUESTED |
requested |
asked for, waiting for the restaurant (or a deposit) |
CONFIRMED |
confirmed |
|
ARRIVED |
arrived |
at the door, not seated yet |
SEATED |
seated |
|
FINISHED |
finished |
paid and gone |
NO_SHOW |
no_show |
|
CANCELLED |
cancelled |
|
REFUSED |
refused |
refused by the restaurant |
UNKNOWN |
unknown |
isFinal() is true for FINISHED, NO_SHOW, CANCELLED and REFUSED.
Slot (readonly), a time a table can be booked at: start (\DateTimeImmutable), covers (the
covers still bookable; 0 is full), end (?\DateTimeImmutable), area (?string, the room or
terrace, when the platform sells them apart).
Notifications
Notification (readonly), what a webhook said, checked:
| Property | Type | |
|---|---|---|
channel |
Channel |
|
event |
string |
the platform's event type: orders.notification, order.new... |
subject |
NotificationSubject |
what it is about |
reference |
?string |
the order's (or reservation's) id, for order() or reservation() |
id |
?string |
the platform's id for the event, to handle it once (platforms retry) |
order |
?Order |
the order, when the webhook carries it whole |
reservation |
?Reservation |
likewise |
status |
OrderStatus|ReservationStatus|null |
the status it announces, when it announces one |
occurredAt |
?\DateTimeImmutable |
|
store |
?string |
the store id it concerns |
raw |
array |
the decoded body |
NotificationSubject: ORDER, COURIER (an order's courier: assigned, arrived, gone),
RESERVATION, STORE, MENU, OTHER.
Token
Omnifood\Model\Token (its properties readonly): accessToken (string), expiresAt
(?\DateTimeImmutable), refreshToken (?string), scopes (list<string>), accountId
(?string).
| Method | |
|---|---|
isExpired(?\DateTimeImmutable $now = null): bool |
expiresAt reached; a token without an end never expires |
isExpiring(int $days = 10, ?\DateTimeImmutable $now = null): bool |
within $days of its end |
toArray(): array |
access_token, expires_at (ATOM), refresh_token, scopes, account_id, the unknown left out: for a database or a cache |
fromArray(array $data): self |
back |
Validator
Omnifood\Validator::validate(Menu $menu, Capabilities $capabilities): list<Violation> checks a
menu against a platform's capabilities before anything is sent: Menu.
Propose a change docs/concepts.md
Orders
OrdersInterface: order($ref), orders($since), accept($ref, $readyAt),
deny($ref, $reason, $note), ready($ref), cancel($ref, $reason, $note). $ref is always
the platform's id for the order (Order::$reference).
The cycle
- A webhook says an order arrived (webhooks);
Notification::$ordercarries it when the platform sends it whole (Deliveroo, Just Eat),order($notification->reference)reads it otherwise (Uber Eats). - The kitchen accepts or denies it within the platform's delay -
capabilities()->acceptanceDelay, in seconds: 690 at Uber Eats, 600 at Deliveroo - or the platform cancels it. Just Eat's orders are accepted on its own tablet before they are pushed: thereaccept()anddeny()say whether the order reached the till. ready()when it is ready for the courier or the customer.- The courier's progress and a cancellation by the platform arrive as webhooks
(
NotificationSubject::COURIER,OrderStatus::CANCELLED).
use Omnifood\Model\DenyReason;
use Omnifood\OrdersInterface;
/** @var OrdersInterface $platform */
$order = $platform->order($ref);
foreach ($order->lines as $line) {
echo $line->quantity, ' x ', $line->name, ' (', $line->posRef, ')';
foreach ($line->modifiers as $modifier) {
echo ' + ', $modifier->name;
}
echo "\n";
}
if ($kitchenCanDoIt) {
$platform->accept($order->reference, new \DateTimeImmutable('+15 minutes'));
} else {
$platform->deny($order->reference, DenyReason::TOO_BUSY, 'Kitchen full until 21:00');
}
$readyAt is sent where the platform takes it (capabilities()->readyAt: Uber Eats only); it is
ignored elsewhere. Line::$posRef is the restaurant's reference when the platform carries it back
(the menu's Item::$ref, pushed as Uber's item id, Deliveroo's pos_item_id, JET's plu).
Listing orders
orders(?\DateTimeImmutable $since = null) gives the orders placed since then, newest first,
every page read. With no date, the last 24 hours. Just Eat lists none (NotSupportedException):
its orders arrive only by webhook, and the application keeps them.
| Uber Eats | Deliveroo | |
|---|---|---|
| Call | GET /v1/delivery/store/{store_id}/orders, pages of 50 (next_page_token) |
GET /order/v2/brand/{brand}/restaurant/{site}/orders (cursor) |
since sent as |
start_time, RFC 3339 |
start_date, UTC Y-m-d\TH:i:s\Z |
| How far back the platform keeps them | 60 days | 30 days |
Deny reasons
DenyReason is Omnifood's; each package maps it to its platform's codes:
DenyReason |
Uber Eats (deny_reason.type, cancellation_reason.type) |
Deliveroo, no tablet (reject_reason) |
Deliveroo, tablet (sync reason) |
Just Eat (errorCode) |
|---|---|---|---|---|
ITEM_UNAVAILABLE |
ITEM_ISSUE |
ingredient_unavailable |
items_out_of_stock |
MENU_ERROR |
CLOSED |
STORE_CLOSED |
closing_early |
location_offline |
STORE_CLOSED |
TOO_BUSY |
RESTAURANT_TOO_BUSY |
busy |
other |
UNKNOWN |
CUSTOMER_REQUEST |
CUSTOMER_CALLED_TO_CANCEL |
other |
other |
UNKNOWN |
ADDRESS |
ADDRESS |
other |
other |
UNKNOWN |
PRICING |
PRICING |
other |
price_mismatched |
MENU_ERROR |
SPECIAL_INSTRUCTIONS |
SPECIAL_INSTRUCTIONS |
other |
other |
UNKNOWN |
TECHNICAL |
TECHNICAL_FAILURE |
other |
other |
UNKNOWN |
OTHER |
OTHER |
other |
other |
UNKNOWN |
The note goes as Uber's info, Deliveroo's notes, JET's errorMessage (the reason's value
when there is no note).
Statuses
How each platform's word becomes an OrderStatus (the platform's own stays in Order::$raw):
OrderStatus |
Uber Eats (state) |
Deliveroo (status, prep_stages) |
Just Eat |
|---|---|---|---|
NEW |
CREATED, OFFERED |
pending, placed |
every order pushed |
ACCEPTED |
ACCEPTED |
accepted, confirmed |
- |
READY |
ACCEPTED with preparation_status READY_FOR_HANDOFF |
accepted, with the stage ready_for_collection |
- |
PICKED_UP |
HANDED_OFF |
accepted, with the stage collected |
driver onItsWay (webhook) |
DELIVERED |
SUCCEEDED |
- | driver delivered (webhook) |
DENIED |
FAILED with failure_info.reason POS_DENIED |
rejected |
- |
CANCELLED |
FAILED otherwise |
canceled |
Cancel Order webhook |
UNKNOWN |
anything else | anything else | - |
What each platform does
| Uber Eats | Deliveroo | Just Eat | |
|---|---|---|---|
order() |
yes | yes | refused (pushed only) |
orders() |
yes (60 days) | yes (30 days) | refused |
accept() |
yes, with a ready time | yes (tablet sites: sync status succeeded) |
the till's confirmation after a 202 (sent-to-pos-success) |
deny() |
yes | yes (tablet sites: sync status failed) |
to the tablet's backup flow (sent-to-pos-failed) |
ready() |
yes | prep stage ready_for_collection |
refused |
cancel() |
yes | refused (Deliveroo's customer service cancels) | refused (done on the tablet) |
And beyond the interface, on the platform's own class:
UberEatsPlatform::updateReadyTime($ref, $readyAt): a new time the order will be ready (accepted, not ready yet, the courier not on the way).DeliverooPlatform::prepStage($ref, $stage, $delay):in_kitchen(with a delay of 0, 2, 4, 6, 8 or 10 minutes),ready_for_collection_soon,ready_for_collection,collected.DeliverooPlatform::syncStatus($ref, $succeeded, $reason, $notes): for a tablet site, whether the order reached the till.
Each platform's manual has the endpoints and every field read.
Propose a change docs/orders.md
Menu
MenuInterface: pushMenu(Menu $menu) and setAvailability($itemRef, $available, $until).
Uber Eats, Deliveroo and Just Eat implement it.
The model
use Omnifood\Model\Allergen;
use Omnifood\Model\Hours;
use Omnifood\Model\Menu\Category;
use Omnifood\Model\Menu\Item;
use Omnifood\Model\Menu\Menu;
use Omnifood\Model\Menu\Modifier;
use Omnifood\Model\Menu\ModifierGroup;
use Omnifood\Model\Money;
$extras = new ModifierGroup('extras', 'Extras', [
new Modifier('egg', 'Egg', Money::of(150, 'EUR')),
new Modifier('nori', 'Nori', Money::of(100, 'EUR')),
], min: 0, max: 2);
$menu = new Menu('Dinner', [
new Category('mains', 'Mains', [
new Item('ramen', 'Shoyu ramen', Money::of(1400, 'EUR'), 'Chicken broth', vatRate: 10.0,
allergens: [Allergen::GLUTEN, Allergen::EGGS], photo: 'https://site.example/ramen.jpg',
modifierGroups: [$extras]),
]),
], 'EUR', new Hours([1 => [['12:00', '14:30'], ['19:00', '22:30']]]), ref: 'dinner');
Every ref is the restaurant's own (its till's): the platforms carry it back on the orders
(Line::$posRef), and setAvailability() takes it. An item listed in two categories, or an option
offered in two groups, is the same thing under the same reference - with the same name and
price. Every field is described in Concepts.
Labels
Item::$labels carries what the model has no field for. The packages read:
| Label | Uber Eats | Deliveroo | Just Eat |
|---|---|---|---|
vegan, vegetarian |
dietary label VEGAN, VEGETARIAN |
diets |
dietary_restrictions (VEGAN first, else VEGETARIAN) |
gluten_free |
GLUTEN_FREE |
diets |
- |
dairy_free, halal, keto, paleo, plant_based |
- | diets |
- |
alcohol |
- | contains_alcohol |
- |
no_allergens |
- | allergies: [no_allergens] (an empty list says "unknown" to Deliveroo) |
- |
| a platform's own allergen codes | - | gluten_wheat, nuts_almond... |
CEREAL_WHEAT, NUTS_ALMONDS... |
Omnifood's GLUTEN and NUTS are the regulation's families; Deliveroo and JET list each cereal
and each nut. An item with Allergen::GLUTEN declares every cereal of the family unless its labels
name the ones it holds (gluten_wheat at Deliveroo, CEREAL_WHEAT at JET); NUTS likewise. The
labels are compared in lower case at Uber Eats and Deliveroo, as written at Just Eat for the
allergen codes. A modifier has no labels.
Checking it
use Omnifood\Validator;
$violations = (new Validator())->validate($menu, $platform->capabilities());
foreach ($violations as $violation) {
echo $violation, "\n"; // path: message
}
Validator checks, against the platform's capabilities:
- the menu: a name, a currency among
currencies(when the platform lists any), at least one category; each category at least one item; - the references: given (not blank), and one thing each - a category's is its own; an item's is
shared with another item only with the same name and price; a modifier's likewise, among the
modifiers, or among the items where
modifiersAreItems(Uber Eats, Deliveroo: an option is an item of the menu); one group reference names one group; - the names: not blank, at most
nameMaxLengthcharacters; descriptions at mostdescriptionMaxLength; - the prices: in the menu's currency, not negative; VAT rates between 0 and 100;
- the modifier groups: not empty,
minat least 0,maxat least 1,minnot abovemaxnor above the number of options; nesting not deeper thanmodifierDepth(and none at all where it is 0).
What a platform ignores is not a violation (a photo where it takes none): only what it would refuse. A menu with every mistake at once, checked against Deliveroo's capabilities:
$extras = new ModifierGroup('extras', 'Extras', [
new Modifier('egg', 'Egg', Money::of(150, 'EUR')),
new Modifier('nori', 'Nori', Money::of(100, 'EUR')),
], min: 3, max: 2);
$menu = new Menu('Dinner', [
new Category('mains', 'Mains', [
new Item('ramen', 'Shoyu ramen', Money::of(1400, 'EUR'), 'Chicken broth', vatRate: 10.0,
allergens: [Allergen::GLUTEN, Allergen::EGGS], modifierGroups: [$extras]),
new Item('ramen', 'Miso ramen', Money::of(1500, 'EUR')),
new Item('gyoza', str_repeat('Gyoza ', 25), Money::of(-100, 'USD'), vatRate: 120.0),
]),
new Category('mains', 'Drinks', []),
], 'EUR');
$deliveroo = (new DeliverooPlatformFactory())->create(); // no key: nothing will be sent
foreach ((new Validator())->validate($menu, $deliveroo->capabilities()) as $violation) {
echo $violation, "\n";
}
try {
$deliveroo->pushMenu($menu);
} catch (InvalidMenuException $e) {
echo count($e->violations), " violations, nothing sent ({$e->platform})\n";
}
categories[0].items[0].modifierGroups[0]: At least 3 but at most 2.
categories[0].items[1].ref: "ramen" is already categories[0].items[0], with another name or price.
categories[0].items[2].name: 150 characters, at most 120.
categories[0].items[2].price: In USD, the menu is in EUR.
categories[0].items[2].price: -1.00 USD: a price is not negative.
categories[0].items[2].vatRate: 120 %: a rate is between 0 and 100.
categories[1].ref: "mains" is already categories[0].
categories[1].items: "Drinks" has no item.
10 violations, nothing sent (deliveroo)
(run on 2026-10-26 in the harness)
pushMenu() runs the validator and throws InvalidMenuException (its platform and its
violations) before sending anything. Deliveroo adds three checks of its own, hence 10: a
category name of at least 3 characters, a VAT rate on every item (items[<ref>].vatRate), and a
picture for the menu (photo: the mealtime_image option, or an item's photo). The harness
checks a JSON menu without sending it: menu:push <platform> <file> --validate.
Pushing it, and items out of stock
$platform->pushMenu($menu); // the platform's menu replaced
$platform->setAvailability('ramen', false, new \DateTimeImmutable('tomorrow 11:00'));
$platform->setAvailability('ramen', true);
pushMenu() replaces the platform's menu whole. setAvailability() takes an item's (or a
modifier's) reference.
| Uber Eats | Deliveroo | Just Eat | |
|---|---|---|---|
| Call | PUT /v2/eats/stores/{store}/menus |
PUT /menu/v1/brands/{brand}/menus/{menu_id} |
POST /menus |
| Options | items of the menu | items of the menu | items nested in the group |
| Allergens | free strings (the EU-14 names) | Deliveroo's list | JET's list (shown in DE, NL, AT, BE, BG, DK, LU, PL, SK) |
| VAT | rate per item (vat_rate_percentage, or tax_rate with tax_inclusive: false) |
rate per item (required) | category: NO_TAX at 0, STANDARD_RATE at standard_vat_rate or above, REDUCED_RATE between |
| Hours | the menu's service hours | the menu's mealtime schedule | the menu's availability |
Item::$available false |
not sent: setAvailability() after the push |
not sent: setAvailability() once the menu webhook says it is in |
out_of_stock |
| Out of stock until | a date (a year from now when none) | unavailable until the next opening when a date is given, hidden when none |
a date (nextAvailableAt), or until said |
| Texts | {translations: {<language>: text}} |
{<language>: text} |
as they are |
Hours::$exceptions of Menu::$hours are not sent by any platform: only the week.
Store
StoreInterface: status(), pause($until, $reason), resume(), setHours(Hours). Uber Eats,
Deliveroo and Just Eat implement it.
use Omnifood\Model\Hours;
$platform->pause(new \DateTimeImmutable('+30 minutes'), 'Rush'); // no new order for half an hour
$platform->resume();
$platform->setHours(new Hours(
[1 => [['12:00', '14:30'], ['19:00', '22:30']], 2 => [['12:00', '14:30']]],
['2026-12-25' => []], // closed that day
));
$status = $platform->status();
if (!$status->isOpen()) {
echo $status->state->value, ' until ', $status->until?->format('H:i') ?? '?', "\n";
}
Hours: per day of the week (1 Monday ... 7 Sunday) its ranges ['HH:MM', 'HH:MM'], a range past
midnight allowed; exceptions per date. isOpenAt() and on() answer for a given time
(Concepts):
$hours = new Hours(
[5 => [['19:00', '01:00'], ['12:00', '14:30']], 6 => [['19:00', '01:00']]],
['2026-12-25' => []],
);
$hours->day(5); // [['12:00', '14:30'], ['19:00', '01:00']]: sorted
$hours->isOpenAt(new \DateTimeImmutable('2026-10-30 13:00')); // true, a Friday
$hours->isOpenAt(new \DateTimeImmutable('2026-10-31 00:30')); // true: Friday's 19:00-01:00
$hours->isOpenAt(new \DateTimeImmutable('2026-12-25 20:00')); // false, closed that day
(run on 2026-10-26 in the harness)
What each platform does
| Uber Eats | Deliveroo | Just Eat | |
|---|---|---|---|
status() |
ONLINE is OPEN, OFFLINE is PAUSED, with is_offline_until and offline_reason |
OPEN is OPEN, CLOSED and READY_TO_OPEN are CLOSED; no end |
refused: JET only pushes it (the Restaurant Temporarily Offline webhook) |
pause($until, $reason) |
OFFLINE, with its end and reason |
CLOSED; the end and reason are not sent: resume() |
offline until $until in the restaurant's local time (none: until resume()); the reason is not sent |
resume() |
ONLINE |
OPEN |
online |
setHours() |
the dates apart only, as holiday hours (each call replaces the previous ones; a closed date is 00:00-00:00); the week is the menu's service hours | the week, replaced whole; the dates apart are not sent | the same week for delivery and collection, with the configured time zone; the dates apart are not sent (a closed day is a pause()) |
Any other state a platform answers is StoreState::UNKNOWN; StoreStatus::$raw keeps the
answer. In AT, BE, BG, CH, DE, LU, NL, PL and SK, JET brings a restaurant back online at 07:00
Amsterdam time the next day whatever end was asked.
Propose a change docs/store.md
Reservations
ReservationsInterface: reservations($from, $to), reservation($ref),
createReservation(Reservation), updateReservation(Reservation),
cancelReservation($ref, $reason), setStatus($ref, ReservationStatus),
pushAvailability(list<Slot>). The writing methods are named after the reservation so that one
platform may take orders and reservations both. TheFork and Zenchef implement it.
use Omnifood\Model\Guest;
use Omnifood\Model\Reservation;
$taken = new Reservation(new \DateTimeImmutable('2026-10-12 19:30'), 4,
new Guest('Jo', 'Martin', 'jo@example.com', '+33612345678', 'fr_FR'), notes: 'A high chair');
$recorded = $thefork->createReservation($taken); // its reference at TheFork
$thefork->updateReservation($recorded->with(covers: 5));
$thefork->cancelReservation($recorded->reference);
foreach ($thefork->reservations(new \DateTimeImmutable('today'), new \DateTimeImmutable('tomorrow')) as $r) {
echo $r->date->format('H:i'), ' ', $r->covers, ' ', $r->guest->name(), ' ', $r->status->value, "\n";
}
reservations($from, $to) gives those whose date falls between the two instants, oldest first.
createReservation() answers the reservation as the platform recorded it, its reference set.
updateReservation() takes a reservation whose reference says which. The models are described
in Concepts.
What each platform does
| TheFork | Zenchef | |
|---|---|---|
reservations() |
yes: the ids booked by meal date, each read (and its customer) | refused: documentation on request |
reservation() |
yes, and the guest's profile (option with_customers) |
refused |
createReservation() |
yes: date, party size, guest, the guest's note, the staff's note (raw['restaurantNote']), an offer (raw['offerUuid']) |
refused |
updateReservation() |
the date, the party size and the staff's note only | refused |
cancelReservation() |
yes, as the restaurant; the reason is not sent | refused |
setStatus() |
CANCELLED only (cancels); the service's statuses are read, not written |
refused |
pushAvailability() |
each slot opened (covers > 0) or closed (0) to online booking | refused |
| Deposits, tables, durations | not in the public API: null |
- |
TheFork's statuses as Omnifood's: RECORDED is CONFIRMED, or ARRIVED (meal status
PARTIALLY_ARRIVED, ARRIVED), SEATED (SEATED, BILL), FINISHED (LEFT); REQUESTED,
CANCELED (CANCELLED), NO_SHOW, REFUSED; anything else UNKNOWN. TheFork's manual has
every field.
Slot covers are a number of places in the model; TheFork takes only open or closed, so any
number above 0 opens the slot.
Propose a change docs/reservations.md
Webhooks
NotifiableInterface::notify(string $body, array $headers): Notification checks a webhook - a
signature, a key, a token - and reads it. It throws InvalidSignatureException when the check
fails, or when the body is not JSON: answer 401 and do nothing. It throws
InvalidConfigException when the secret it checks with is not configured.
// webhook.php, in plain PHP; $ubereats from the registry
try {
$notification = $ubereats->notify((string) file_get_contents('php://input'), getallheaders());
} catch (Omnifood\Exception\InvalidSignatureException) {
http_response_code(401);
exit;
}
$queue->push($notification->channel->value, $notification->id, $notification->reference); // the application's queue
http_response_code(200);
The body is the raw one, as received; the headers an array, names in any case, values a string
or a list of strings (the first is read), as the request has them. In a Symfony controller:
$request->getContent() and $request->headers->all() (Symfony).
Answer fast and work later (a queue, Messenger): the platforms retry when the answer is slow or
not 2xx, and send the same event more than once, out of order. Notification::$id is the
platform's event id, or one composed from the order and its status (the same on a retry): keep the
ids handled, and handle each once.
A Uber Eats notification, checked and read (run on 2026-10-26 in the harness):
$body = '{"event_id":"e-1","event_type":"orders.notification","event_time":1759579200,"meta":{"resource_id":"o-1","user_id":"store-1"}}';
$n = $ubereats->notify($body, ['X-Uber-Signature' => hash_hmac('sha256', $body, $clientSecret)]);
echo $n->event, ' ', $n->subject->value, ' ', $n->reference, ' ', $n->id, ' ', $n->status->value, ' ', $n->occurredAt->format(DATE_ATOM), ' ', $n->store, "\n";
orders.notification order o-1 e-1 new 2025-10-04T12:00:00+00:00 store-1
and with a wrong signature, InvalidSignatureException:
[ubereats] The X-Uber-Signature signature does not match the body.
The Notification's fields are in Concepts.
What each platform checks
| Check | Configured by | Carries | |
|---|---|---|---|
| Uber Eats | X-Uber-Signature: lowercase hex HMAC-SHA256 of the body |
client_secret |
the event and the order's id: order($n->reference) |
| Deliveroo | X-Deliveroo-Hmac-Sha256: hex HMAC-SHA256 of X-Deliveroo-Sequence-Guid, a space and the body |
webhook_secret |
the whole order; the rider's status; the menu's upload result |
| Just Eat | Authorization equal to the key given to JET; X-JET-Connect-Hash (signature= base64 HMAC-SHA256 of the body) |
webhook_key, webhook_secret (one at least; the secret alone refuses a webhook without the hash) |
the whole order; cancellations, the driver, the restaurant set offline, the backup flow |
| TheFork | the token put in the URL given to TheFork, passed among the headers as token |
webhook_token |
the entity and its id: reservation($n->reference) |
| Zenchef | refused: not public | - | - |
Events, ids and statuses
event |
subject |
id |
status |
|
|---|---|---|---|---|
| Uber Eats | Uber's event_type |
orders.* order, delivery.* courier, store.menu* menu, store.* store |
Uber's event_id |
orders.notification, orders.scheduled.notification: NEW; orders.cancel, orders.failure: CANCELLED |
| Deliveroo | Deliveroo's event |
an order body: order; rider.status_update: courier; menu.upload_result: menu |
an order: <id>:<status>; else the sequence GUID |
the order's status; a rider rider_in_transit: PICKED_UP |
| Just Eat | order.received, order.cancelled, driver.<code>, restaurant.temp_offline, order.backup_flow, unknown |
order, courier or store | <order>:received[:<transmission_id>], <order>:cancelled, <order>:driver:<code>, <restaurant>:offline:<time>, <order>:backup |
NEW, CANCELLED, driver onItsWay PICKED_UP, delivered DELIVERED |
| TheFork | TheFork's eventType |
entityType reservation: reservation; else other |
null: TheFork gives none |
null |
Answering
- Uber Eats: 200, empty.
- Deliveroo: 200.
- Just Eat's Receive Order is answered
{"OrderId": "..."}with 200 (taken in) or 202 (taken in later: thenaccept()ordeny()within 5 minutes); the other webhooks 200 with the same body. A 500 makes JET retry, up to 5 times. - TheFork: 200
{"data": {}}within a few seconds.
Propose a change docs/webhooks.md
Configuration reference
The registry
use Omnifood\Registry;
use Omnifood\TheFork\TheForkPlatformFactory;
use Omnifood\UberEats\UberEatsPlatformFactory;
use Symfony\Component\HttpClient\HttpClient;
$http = HttpClient::create();
$registry = new Registry([new UberEatsPlatformFactory($http), new TheForkPlatformFactory($http)], [
'ubereats' => ['factory' => 'ubereats', 'options' => [
'client_id' => getenv('UBEREATS_CLIENT_ID') ?: null,
'client_secret' => getenv('UBEREATS_CLIENT_SECRET') ?: null,
'store_id' => getenv('UBEREATS_STORE_ID') ?: null,
]],
'thefork' => ['factory' => 'thefork', 'options' => [
'client_id' => getenv('THEFORK_CLIENT_ID') ?: null,
'client_secret' => getenv('THEFORK_CLIENT_SECRET') ?: null,
'restaurant_id' => getenv('THEFORK_RESTAURANT_ID') ?: null,
'webhook_token' => getenv('THEFORK_WEBHOOK_TOKEN') ?: null,
]],
]);
$ubereats = $registry->get('ubereats'); // built once, the first time it is asked for
$registry->orders(); // the platforms that take orders
$registry->reservations(); // the ones that take reservations
Each entry is a name (any), a factory (the package's: ubereats, deliveroo, justeat,
thefork, zenchef) and its options. Two Uber Eats stores are two entries with the same
factory. In a Symfony application the same entries are written in YAML, under
omnifood.platforms (Symfony).
| Key | Type | Required | |
|---|---|---|---|
<name>.factory |
string | yes | a factory's getName(); one not given to the registry is an InvalidConfigException when the platform is asked for |
<name>.options |
array | no ([]) |
the platform's options, below |
Options left empty
An option given as null or '' - an environment variable not set - is removed before the
defaults are applied: the factory's default stands. false, 0 and [] are kept as given.
Booleans are read with FILTER_VALIDATE_BOOL, so 'true', '1', 'on' and 'yes' are true
and any other string is false: an environment variable can be passed as it is.
Nothing breaks before a key is set
No platform declares a required option: a platform is built with none, and a key left empty
only shows when a call needs it, as an InvalidConfigException naming it. capabilities() and
the menu validator need no key. So a site boots before its accounts exist:
use Omnifood\Exception\InvalidConfigException;
$registry->has('ubereats'); // declared
try {
$registry->get('ubereats')->orders();
} catch (InvalidConfigException $e) {
// declared, not configured: The "ubereats" platform needs: client_id, client_secret.
}
Keys typed in a back office
$platform = $registry->create('ubereats', [
'client_id' => $settings->get('ubereats.client_id'),
'client_secret' => $settings->get('ubereats.client_secret'),
]);
create() builds a platform afresh, the configured options with the given ones over them
(array_replace); it is not kept (get() still gives the configured one).
Tokens
Uber Eats, Deliveroo and TheFork hand out client-credentials tokens: the platform asks for one when a call needs it and keeps it, in that object, while it lives.
| Lifetime | Asked for again | Kept by the site | |
|---|---|---|---|
| Uber Eats | 30 days (expires_in) |
5 minutes before it dies | access_token and token_expires_at options: Uber allows 100 token requests an hour |
| Deliveroo | 5 minutes | 20 seconds before it dies | no option: each process asks for its own |
| TheFork | about 2 h 23 | 60 seconds before it dies | access_token and token_expires_at options: TheFork asks that no new token be requested before |
A site that runs many processes (workers, PHP-FPM) keeps the token where they all read it:
use Omnifood\Model\Token;
$kept = $cache->get('ubereats.token'); // an array, or null
$platform = $registry->create('ubereats', $kept ? [
'access_token' => $kept['access_token'],
'token_expires_at' => $kept['expires_at'] ?? null,
] : []);
$platform->orders(); // asks for a token only if none is held or it is dying
$cache->set('ubereats.token', $platform->token()->toArray());
// a scheduled job, before it dies:
if (null === $kept || Token::fromArray($kept)->isExpiring(2)) {
$cache->set('ubereats.token', $platform->refresh()->toArray());
}
token() is the token held (null before the first call), refresh() asks for a new one now
(RefreshableInterface). token_expires_at is read by new \DateTimeImmutable(): ATOM, as
Token::toArray() writes it.
Linking a merchant's account (OAuth)
Uber Eats implements OAuthInterface, for the merchant's consent to link a store to the
application (scope eats.pos_provisioning):
$url = $ubereats->authorizationUrl('https://site.example/admin/ubereats/callback', $state);
// the merchant is sent there; back on the callback, with ?code=...&state=...:
$token = $ubereats->exchange($code, 'https://site.example/admin/ubereats/callback');
authorizationUrl() is https://auth.uber.com/oauth/v2/authorize with client_id,
response_type=code, the redirect URI, the scopes (eats.pos_provisioning unless others are
given) and the state; checking the state on the way back is the application's job. The token
exchange() gives (with a refresh token) is for the provisioning calls, not for the orders.
Every platform's options
Read from each factory's populate() and build(). Every option is optional when the platform
is built; "needed by" says which calls fail without it.
ubereats
| Option | Type | Default | Needed by | |
|---|---|---|---|---|
client_id |
string | - | every call (the token), authorizationUrl() |
the Developer Dashboard application's |
client_secret |
string | - | every call, notify() |
the application's secret; also the webhooks' signing key |
store_id |
string | - | orders(), the menu, the store |
the store's id at Uber |
scopes |
list, or a string split on spaces and commas | eats.store, eats.order, eats.store.orders.read, eats.store.status.write |
the client-credentials token's scopes | |
access_token |
string | - | a token kept by the site | |
token_expires_at |
string (a date) | - | when it dies | |
language |
string | en_us |
the key of the menu's translations | |
tax_inclusive |
bool | true |
true: items carry vat_rate_percentage (prices include VAT); false: tax_rate |
|
sandbox |
bool | false |
https://test-api.uber.com and https://sandbox-login.uber.com/oauth/v2/token |
|
base_uri |
string | https://api.uber.com |
another API address | |
token_url |
string | https://auth.uber.com/oauth/v2/token |
another token endpoint |
deliveroo
| Option | Type | Default | Needed by | |
|---|---|---|---|---|
client_id, client_secret |
string | - | every call (the token, HTTP Basic) | the Developer Portal application's |
brand_id |
string | - | orders(), the menu, the site |
from Deliveroo's integration team |
site_id |
string | - | orders(), the menu, the site |
the restaurant's location id |
menu_id |
string | - | pushMenu(), setAvailability() |
the menu written to |
webhook_secret |
string | - | notify() |
from the account manager |
sandbox |
bool | false |
https://api-sandbox.developers.deliveroo.com, https://auth-sandbox.developers.deliveroo.com/oauth2/token |
|
tablet |
bool | false |
the site has a Deliveroo tablet: accept() and deny() send the till's sync status |
|
language |
string | fr |
the language key of the menu's texts | |
mealtime_image |
string (URL) | - | the menu's picture (Deliveroo requires one), else the first item's photo | |
base_uri |
string | https://api.developers.deliveroo.com |
||
token_url |
string | https://auth.developers.deliveroo.com/oauth2/token |
justeat
| Option | Type | Default | Needed by | |
|---|---|---|---|---|
api_key |
string | - | every call | JET Connect's key for the brand (X-Flyt-Api-Key) |
restaurant |
string | - | the menu, the restaurant | the restaurant's reference at JET Connect (posLocationId) |
webhook_key |
string | - | notify() (or webhook_secret) |
the key given to JET for its webhooks, sent back as Authorization |
webhook_secret |
string | - | notify() (or webhook_key) |
the HMAC secret given to JET (X-JET-Connect-Hash) |
currency |
string | GBP |
the orders' amounts come without one | |
timezone |
string | Europe/London |
the restaurant's: service times, the end of a pause; an unknown zone fails when the platform is built (PHP's \Exception) |
|
menu_type |
string | DELIVERY |
or COLLECTION (upper-cased) |
|
standard_vat_rate |
float | 20.0 |
at or above: STANDARD_RATE; above 0 and under: REDUCED_RATE; 0: NO_TAX |
|
base_uri |
string | https://api.flytplatform.com |
thefork
| Option | Type | Default | Needed by | |
|---|---|---|---|---|
client_id, client_secret |
string | - | every call (the token) | sent by TheFork |
restaurant_id |
string | - | reservations(), createReservation(), pushAvailability() |
the restaurant's UUID at TheFork |
webhook_token |
string | - | notify() |
the token put in the webhook URL given to TheFork |
access_token |
string | - | a token kept by the site | |
token_expires_at |
string (a date) | - | when it dies | |
locale |
string | fr_FR |
a guest's, when the reservation does not say | |
civility |
string | mx |
a guest's, required by TheFork, when raw['civility'] does not say |
|
with_customers |
bool | true |
read each reservation's customer (name, phone, allergies): one call more each | |
base_uri |
string | https://api.thefork.io/manager |
||
token_url |
string | https://auth.thefork.io/oauth/token |
zenchef
| Option | Type | Default | Needed by | |
|---|---|---|---|---|
token |
string | - | api()->request() |
Settings > Partners in Zenchef: "Zenchef token" |
restaurant_id |
string | - | api()->request() |
Settings > Partners |
base_uri |
string | - | api()->request() |
from the documentation Zenchef sends on request |
headers |
map | {} |
api()->request() |
from it too; {token} and {restaurant_id} in a value stand for the two above |
Every reservation call of Zenchef is refused whatever is configured; api() is the configured
client (its manual).
Propose a change docs/configuration.md
Errors
Every exception Omnifood throws on purpose implements Omnifood\Exception\OmnifoodException (a
\Throwable):
| Exception | Extends | When |
|---|---|---|
InvalidConfigException |
\LogicException |
a platform not configured, a factory not given to the registry, a required option missing, a key a call needs and finds empty (The "ubereats" platform needs: store_id.) |
NotSupportedException |
\LogicException |
the platform does not do that, or its public documentation does not show how: thrown before anything is sent (The "justeat" platform does not read an order (...)) |
InvalidMenuException |
\InvalidArgumentException |
pushMenu() found violations: nothing was sent. platform, violations (list<Violation>); the message joins them: [deliveroo] path: message; path: message |
InvalidSignatureException |
\RuntimeException |
a webhook whose signature, key or token does not hold, or whose body is not JSON. platform. Answer 401, handle nothing |
ProviderException |
\RuntimeException |
the platform refused, or could not be reached |
UnauthorizedException |
ProviderException |
the platform answered 401 or 403: the keys refused, a scope missing, the account to connect again |
Outside the family's exceptions: Hours' constructor throws \InvalidArgumentException for a
day, a time or a range it cannot read (Concepts); TheFork's
updateReservation() a \InvalidArgumentException for a reservation without a reference, and
pushAvailability() for an entry that is not a Slot, createReservation() for a national
phone number in no country it knows (omnifood/thefork).
An option a factory cannot read as a date or a time zone - Just Eat's timezone, Uber Eats' and
TheFork's token_expires_at - is an InvalidConfigException (PHP's own exception as its previous).
ProviderException
use Omnifood\Exception\ProviderException;
use Omnifood\Exception\UnauthorizedException;
try {
$platform->accept($ref);
} catch (UnauthorizedException $e) {
// the keys: alert someone, do not retry
} catch (ProviderException $e) {
$e->platform; // "ubereats", "deliveroo"...
$e->status; // the HTTP status, null when the platform could not be reached
$e->providerCode; // the platform's own error code, when it gave one; else null
$e->getMessage(); // "[ubereats] <the platform's message>"
$e->getPrevious(); // a copy of the HTTP client's exception, for a network failure
}
What each package turns into which exception (checked on recorded answers on 2026-10-26 for Uber Eats; the four others follow the same code):
| The platform answers | Exception | Message |
|---|---|---|
| 401, 403 | UnauthorizedException |
the platform's message: [ubereats] Invalid token. |
| 429 | ProviderException |
[ubereats] Too many calls, try again later: Slow down. |
| another 4xx or 5xx | ProviderException |
its message, else [ubereats] HTTP 500. |
| a network failure | ProviderException, status null |
[ubereats] Uber could not be reached: Connection refused |
| a token answer without a token | ProviderException |
[ubereats] No token in Uber's answer. |
| a 2xx whose body is not JSON (a proxy's page) | ProviderException, its status |
[ubereats] An answer that is not JSON (HTTP 200). |
The message and the code are read from each platform's error shape: Uber's {code, message},
Deliveroo's {error: {code, message}}, JET's {success: false, error_message}, {message} or
{fault: {errors: [...]}}, TheFork's {data: {code}, error, statusCode} or {message}, the
OAuth endpoints' {error, error_description}. A JET answer with "success": false is a
ProviderException whatever its status; so is a 2xx answer whose body is not JSON, on every
platform, where it was read as an empty answer.
No secret in a failure
What a platform throws keeps the request's secrets out, for a log that writes the whole chain,
arguments included (zend.exception_ignore_args off). The HTTP client's message quotes the URL
called: in the ProviderException's message its key parameters, a password in its user part and every secret
of the request (a key, a password, a bearer or basic credential) are masked as ***
(Omnifood\Http\Redaction::text()). The previous exception is a copy of the client's
(Redaction::copy(): the same class and code, that message, its own previous copied the same
way, without the response or request a client's exception holds), and no trace of the chain keeps
its arguments, where the options, headers and body stood (Redaction::withoutArguments()). A
PlatformFactory::create() that fails (a missing option) throws without arguments in its trace
either: they held the options, credentials included.
What to catch where
| Where | Catch |
|---|---|
| a webhook controller | InvalidSignatureException: answer 401; InvalidConfigException: the secret is not configured, answer 500 so that the platform retries once it is |
| accepting an order | ProviderException: the delay runs (acceptanceDelay), retry soon, then tell the kitchen; UnauthorizedException: alert someone |
| pushing a menu | InvalidMenuException: show its violations in the back office; ProviderException |
| a feature a platform may lack | check the interface (instanceof) and capabilities() first; NotSupportedException is a programming error |
| building the registry, a deploy | InvalidConfigException: a configuration error, to fix, not to retry |
A platform's message and code are the platform's, untranslated: show the restaurant a message of the site's, keep the platform's for the logs.
Propose a change docs/errors.md
Testing
Testing an application that uses Omnifood. The core ships no fake platform and no conformance suite: an application tests against a platform package on recorded answers, or against a platform of its own.
A platform on recorded answers
Give the platform's factory a MockHttpClient (symfony/http-client) that answers as the platform
would. The token endpoint is called first, on the first call that needs a token:
use Omnifood\Exception\ProviderException;
use Omnifood\Model\OrderStatus;
use Omnifood\UberEats\UberEatsPlatformFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class KitchenTest extends TestCase
{
private function ubereats(MockResponse ...$answers)
{
$http = new MockHttpClient(static function (string $method, string $url) use (&$answers): MockResponse {
return str_contains($url, '/oauth/v2/token')
? new MockResponse('{"access_token": "test", "expires_in": 2592000}')
: array_shift($answers);
});
return (new UberEatsPlatformFactory($http))->create(['client_id' => 'id', 'client_secret' => 'secret', 'store_id' => 'store-1']);
}
public function testANewOrderIsShown(): void
{
$ubereats = $this->ubereats(new MockResponse((string) file_get_contents(__DIR__.'/fixtures/ubereats-order.json')));
self::assertSame(OrderStatus::NEW, $ubereats->order('o-1')->status);
}
public function testUberDownIsAProviderException(): void
{
$this->expectException(ProviderException::class);
$this->ubereats(new MockResponse('', ['http_code' => 503]))->accept('o-1');
}
}
A recorded order to start from: docker/harness/recorded/ubereats-order.json in this package;
each platform package's Tests/ holds the answers its own tests record, written from the
platform's documentation.
Webhooks
notify() needs nothing but the secret: sign the body in the test as the platform does.
$body = '{"event_id": "e-1", "event_type": "orders.notification", "meta": {"resource_id": "o-1"}}';
$notification = $ubereats->notify($body, ['X-Uber-Signature' => hash_hmac('sha256', $body, 'secret')]);
Deliveroo signs <guid> <body> with the webhook secret
(X-Deliveroo-Sequence-Guid, X-Deliveroo-Hmac-Sha256); Just Eat sends the configured key as
Authorization and HMAC-SHA256 t=...,signature=<base64> as X-JET-Connect-Hash; TheFork's
token goes among the headers as token.
A platform of the application's own
To test the application's code without any platform package, a class implementing the interfaces
it uses, and a factory for it (Writing a platform), configured under the name the
application uses. The core's own tests do so (Tests/StubFactory.php: a platform that records
what it was asked).
Menus
Validator needs no platform: check a menu against new Capabilities(...) with the limits to
test, or against a platform built with no key ((new DeliverooPlatformFactory())->create()->capabilities()).
In a Symfony functional test
The registry and the platforms are services: replace a platform's options per environment
(config/packages/test/omnifood.yaml, a factory of the application's own), or replace the HTTP
client the factories receive (http_client) with a MockHttpClient in the test environment, as
for any Symfony HTTP client test.
Propose a change docs/testing.md
The Docker harness
docker/ in this package runs it with every omnifood/* platform installed and a console to call
them with real keys. The image is omnisense/omnifood-harness (PHP 8.4, Composer); the harness
writes its composer.json and phpunit.xml into a volume (docker/harness/setup.php) and installs
them when they change.
cd vendor/omnisense/omnifood/docker # or the checkout's docker/
cp .env.dist .env # fill in the keys at hand
docker compose run --rm omnifood platforms
The platforms come from GitHub (branch 1.x), or from the checkouts beside the core when
OMNIFOOD_PLUGINS=../.. is set in .env (the workspace layout).
| Command | Does |
|---|---|
platforms |
installed, configured (which variables are missing), what each does |
capabilities <platform> |
what it takes, as JSON (no key) |
menu:push <platform> <file> [--validate] |
a menu from JSON (menu.example.json): checked, or sent |
orders <platform> [--since], order <platform> <id> |
the orders, as JSON |
accept, deny --reason --note, ready |
the order's cycle |
store:status <platform> |
open or not |
reservations <platform> [--from --to] |
the reservations, as JSON |
notify <platform> <body file> -H "Name: value" |
a webhook checked and read |
refresh <platform>, authorize <platform> <redirect-uri> |
tokens |
bare |
plain PHP: the registry built by hand, an order read from a recorded answer, what PHP loaded |
test |
every package's tests |
Keys
docker/.env.dist lists every variable the console reads (docker/harness/config/platforms.php):
UBEREATS_CLIENT_ID, UBEREATS_CLIENT_SECRET, UBEREATS_STORE_ID, UBEREATS_SANDBOX;
DELIVEROO_CLIENT_ID, DELIVEROO_CLIENT_SECRET, DELIVEROO_BRAND_ID, DELIVEROO_SITE_ID,
DELIVEROO_MENU_ID, DELIVEROO_WEBHOOK_SECRET, DELIVEROO_SANDBOX, DELIVEROO_TABLET;
JUSTEAT_API_KEY, JUSTEAT_RESTAURANT, JUSTEAT_WEBHOOK_KEY, JUSTEAT_WEBHOOK_SECRET,
JUSTEAT_CURRENCY, JUSTEAT_TIMEZONE; THEFORK_CLIENT_ID, THEFORK_CLIENT_SECRET,
THEFORK_RESTAURANT_ID, THEFORK_WEBHOOK_TOKEN; ZENCHEF_TOKEN, ZENCHEF_RESTAURANT_ID,
ZENCHEF_BASE_URI. Every installed platform is built whatever is set - capabilities and
menu:push --validate need no key - and platforms lists the variables each one still needs.
The console passes no Zenchef headers, so Zenchef's client is not callable from it.
A command that fails prints the exception's message: exit code 2 for an InvalidConfigException
(a key missing), 1 for any other Omnifood exception; an InvalidMenuException lists its
violations ("Refused before anything was sent").
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 platform packages installed, asks each what it takes,
reads an Uber Eats order from a recorded answer (docker/harness/recorded/), then lists what PHP
loaded and exits 1 if a class of a framework is among it (Symfony\Component\DependencyInjection,
Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig):
$ docker compose run --rm omnifood bare
Omnifood in bare PHP: the registry built by hand, no bundle, no container.
ubereats orders menu store notify accepts within 690 s
deliveroo orders menu store notify accepts within 600 s
justeat orders menu store notify
thefork reservations notify
zenchef reservations notify
Uber Eats, order o-1 from a recorded answer:
#A1B2C delivery new, 2 x Shoyu ramen, 31.90 EUR, for Aiko T., ready at 2026-10-04T12:15:00+00:00
Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
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. (Output above run again on 2026-10-26, identical.)
Running a script of your own
The entrypoint runs php as it is, with the harness's autoloader at /harness/vendor/autoload.php:
docker compose run --rm -v "$PWD/scratch:/scratch" omnifood php /scratch/try.php
The examples of this manual were run that way. docker compose run --rm omnifood test runs every
package's tests in one PHPUnit run (89 tests on 2026-10-26).
Propose a change docs/harness.md
Writing a platform
A platform package - DoorDash, Wolt, a till's own API - is a factory and a platform class, in a
package of its own (omnifood/<slug>, namespace Omnifood\<Platform>\), requiring
omnisense/omnifood and symfony/http-client. The five packages share one layout: a
<Platform>PlatformFactory, a <Platform>Platform, an Api that does the HTTP and the errors.
The factory
namespace Omnifood\Wolt;
use Omnifood\Config;
use Omnifood\PlatformFactory;
use Omnifood\PlatformInterface;
final class WoltPlatformFactory extends PlatformFactory
{
protected function populate(Config $config): void
{
$config->defaults([
'omnifood.factory_name' => 'wolt',
'omnifood.required_options' => [], // check keys when a call needs them
'api_key' => null,
'venue_id' => null,
'base_uri' => Api::BASE_URI,
]);
}
protected function build(Config $config): PlatformInterface
{
return new WoltPlatform(
new Api($config['api_key'] ? (string) $config['api_key'] : null, (string) $config['base_uri'], $this->http),
$config['venue_id'] ? (string) $config['venue_id'] : null,
);
}
}
populate()names the factory (snake_case, the name it is configured with) and gives every option its default. Options given asnullor''were removed before: the defaults stand.- Keep
omnifood.required_optionsempty, as the five packages do, and throwInvalidConfigException('The "wolt" platform needs: api_key.')when a call finds the key empty: thencapabilities()and the menu validator work with no key, and a site boots before its accounts exist. $this->httpis the HTTP client given to the factory, ornull: theApithen callsHttpClient::create().- Build nothing that calls: no token, no request in
build().
The platform
Implement PlatformInterface and the capability interfaces the platform offers. A method the
interface requires and the platform does not document throws
NotSupportedException::operation($this->getName(), 'cancel an order (why)'), before any call.
capabilities(): what the platform takes, without a key (Concepts). The validator checks menus against it, so the limits there must be the platform's.pushMenu(): runValidator::validate($menu, $this->capabilities()), add the platform's own checks asViolations, throwInvalidMenuExceptionbefore sending.- Orders and reservations: map the platform's statuses to
OrderStatus/ReservationStatus(UNKNOWNfor anything else), amounts toMoneyin minor units, keep the answer in$raw. notify(): lower-case the header names, take the first value of a list, check the signature withhash_equals()before decoding, throwInvalidSignatureExceptionfor a bad signature or a body that is not JSON, and giveNotification::$ida value that is the same on a retry.- Tokens: implement
Auth\RefreshableInterfacewhen the platform hands out tokens, and take anaccess_token/token_expires_atoption when the platform limits how often one may be asked for.
The Api
One send() for every call: the HTTP client's exceptions as
ProviderException($platform, '... could not be reached: '.$e->getMessage(), null, null, $e),
401 and 403 as UnauthorizedException, any other status of 400 or more as ProviderException
with the platform's message and code. A 2xx body that is not JSON should be a ProviderException
too - the five packages read it as an empty answer today (Errors).
Registering it
- Plain PHP: give the factory to the
Registry. - Symfony: a class implementing
PlatformFactoryInterfaceis autoconfigured (tagomnifood.platform_factory); a published package is added toOmnifoodBundle::FACTORIESso that it is registered with the application'shttp_client. - The harness: a line in
docker/harness/plugins.php(slug, tests namespace, factory class) and its variables indocker/harness/config/platforms.phpand.env.dist.
Tests
There is no conformance suite in the core yet. Each package tests itself on recorded answers with
a MockHttpClient (Tests/<Platform>PlatformTest.php): every call's method, path, query and body,
the mapping of a recorded order, the statuses, the menu's shape, the webhook's check, the errors
(401, 429, 500, a network failure) and that nothing but capabilities() and the validator works
without keys.
Propose a change docs/providers.md
Verified, and not
What the tests prove
Run on 2026-10-26 in the harness, every package from its checkout, PHP 8.4.27, PHPUnit 11.5.57: 89 tests, 479 assertions, green.
The core's tests cover the registry (built once, by capability, afresh with keys typed in a back
office, the unknown platform and factory, required options, empty options), the validator
(references, shared modifiers, modifiers as items, bounds, depth, names, prices, rates), Hours,
Money, Token, Reservation::with(), the Symfony bundle in a booted kernel
(Tests/Bridge/OmnifoodBundleTest.php: platforms injected by name as what they do, the packages
installed registered, a platform with no keys breaking nothing) and the plain-PHP run with no
framework class loaded (Tests/BareTest.php).
Each platform package tests, on recorded answers written from the platform's documentation: every call it makes (method, path, query, body), the mapping of a recorded order or reservation, the statuses, the menu's shape, the webhook's signature (Deliveroo's against its documented example), the error mapping, the sandbox addresses, and that only what needs no key works without keys.
The capabilities table of Concepts and the interfaces in the index were probed on 2026-10-26; the examples of this manual were run in the harness the same day.
What is not verified
- That the platforms answer as recorded. No platform gives API keys without a partner agreement:
Uber Eats, Deliveroo, Just Eat and TheFork are written from their public documentation, read in
October 2026, and none was called for this manual. Uber Eats and Deliveroo have sandboxes
(
sandbox: true) to start from. - Zenchef's API is documented on request only:
omnifood/zenchefholds the credentials and a configured client, and refuses every reservation call. - There is no conformance suite: the packages share their rules by convention. The defects found while writing these manuals (a 2xx body that is not JSON read as an empty answer, Just Eat's webhooks unchecked with only an HMAC secret, TheFork's phone numbers, the factories' date options) are fixed since, each with its test in its package.
- The Laravel page was written from the registry's API, not run in a Laravel application.
Responsibility
omnisense/omnifood and its omnifood/* platforms are independent, unofficial clients: none is
affiliated with, endorsed by or sponsored by the services it reaches. Each service keeps its own
terms (partner agreements, keys, rate limits, how orders and guests' data may be used), named with
a link in each platform's README. The MIT licence covers this code only and grants no right to any
service or to its data; whoever runs these packages complies with those terms.