Skip to content

omnisense/omnifood

Omnifood: one contract for the restaurant platforms - delivery orders (Uber Eats, Deliveroo, Just Eat) and table reservations (TheFork, Zenchef) - their menus, their stores, their webhooks, and its Symfony bundle.

Floor
Family
Version
1.x
Verified for real
Not verified for real
Licence
MIT
composer require omnisense/omnifood:1.x-dev

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

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/omnifood requires symfony/http-client-contracts (^3.0) and nothing else.
  • Each platform package requires omnisense/omnifood (1.x) and symfony/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 call mb_strlen(): omnisense/omnifood requires ext-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, a MockHttpClient in 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\OmnifoodBundle registers the factories on the application's http_client, builds the registry from config/packages/omnifood.yaml and 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 Registry once, 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()

(Writing a platform)

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():

  1. createConfig($options) - the options with every null and '' removed, in a Config (an ArrayObject); then the factory's populate() sets its name, its required options and the default of each option not given (Config::defaults() sets only keys not set yet);
  2. the keys listed in omnifood.required_options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key (The "x" platform needs: a, b.);
  3. 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] in HH: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 $at falls 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

  1. A webhook says an order arrived (webhooks); Notification::$order carries it when the platform sends it whole (Deliveroo, Just Eat), order($notification->reference) reads it otherwise (Uber Eats).
  2. 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: there accept() and deny() say whether the order reached the till.
  3. ready() when it is ready for the courier or the customer.
  4. 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

MenuInterface: pushMenu(Menu $menu) and setAvailability($itemRef, $available, $until). Uber Eats, Deliveroo and Just Eat implement it.

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.

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.

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 nameMaxLength characters; descriptions at most descriptionMaxLength;
  • the prices: in the menu's currency, not negative; VAT rates between 0 and 100;
  • the modifier groups: not empty, min at least 0, max at least 1, min not above max nor above the number of options; nesting not deeper than modifierDepth (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.

$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.

Propose a change docs/menu.md

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: then accept() or deny() 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 as null or '' were removed before: the defaults stand.
  • Keep omnifood.required_options empty, as the five packages do, and throw InvalidConfigException('The "wolt" platform needs: api_key.') when a call finds the key empty: then capabilities() and the menu validator work with no key, and a site boots before its accounts exist.
  • $this->http is the HTTP client given to the factory, or null: the Api then calls HttpClient::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(): run Validator::validate($menu, $this->capabilities()), add the platform's own checks as Violations, throw InvalidMenuException before sending.
  • Orders and reservations: map the platform's statuses to OrderStatus / ReservationStatus (UNKNOWN for anything else), amounts to Money in minor units, keep the answer in $raw.
  • notify(): lower-case the header names, take the first value of a list, check the signature with hash_equals() before decoding, throw InvalidSignatureException for a bad signature or a body that is not JSON, and give Notification::$id a value that is the same on a retry.
  • Tokens: implement Auth\RefreshableInterface when the platform hands out tokens, and take an access_token / token_expires_at option 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 PlatformFactoryInterface is autoconfigured (tag omnifood.platform_factory); a published package is added to OmnifoodBundle::FACTORIES so that it is registered with the application's http_client.
  • The harness: a line in docker/harness/plugins.php (slug, tests namespace, factory class) and its variables in docker/harness/config/platforms.php and .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/zenchef holds 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.

Propose a change docs/verified.md