Aller au contenu

omnisense/omniticket

Omniticket: tickets sold elsewhere, read and checked under any PHP host - the events a ticketing service sells (dates, rates, places left, sold out, where to buy), who comes (attendees), and whether a ticket code is valid at the door, one contract whoever sells them. Providers are packages of their own (omniticket/weezevent, omniticket/billetweb, omniticket/helloasso, omniticket/eventbrite, omniticket/ticketmaster); the Symfony and Laravel bridges live in omnibridge.

Étage
Famille
Version
1.x
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omniticket:1.x-dev

Omniticket reads what a ticketing service sells and who comes, under any PHP host: an organizer's events (dates, status, rates and their prices, sessions, places left, where to buy and the purchase module to embed), their attendees (one per ticket, cancelled and refunded ones included), and a ticket code checked at the door - from Weezevent, Billetweb, HelloAsso, Eventbrite, or Ticketmaster's public catalogue, through one set of models.

foreach ($registry->catalog('box_office')->events(new EventQuery(limit: 20)) as $event) {
    echo $event->start?->format('d/m H:i'), ' ', $event->title, $event->soldOut() ? ' (complet)' : '', "\n";
}
$event = $registry->catalog('box_office')->event('1235');                // rates, sessions, places left
$registry->attendees('box_office')->attendees('1235', $lastRead);        // what changed since
$registry->checker('box_office')->check('1235', $scannedCode)->result;   // CheckResult::Valid, AlreadyUsed...

Each provider implements the contracts it can answer - EventCatalogInterface, AttendeeListInterface, TicketCheckInterface - and says so, with the terms and rate limits it must keep, in its Capabilities. An EventQuery (a period, a keyword, past events or not, a limit) is applied by the core to what every provider returns, so one query means the same everywhere. The design is the Omnisense families' one: a provider's factory builds it from options, a registry holds the application's providers by name. The core requires symfony/http-client-contracts (interfaces only) and nothing else; the HTTP client is the application's.

Published as omnisense/omniticket, formerly omniscient/omniticket: the package replaces the old name. The namespace is Omniticket\; providers keep the omniticket/ vendor and the Omniticket\<Provider>\ namespace.

When to use it

  • A venue's, a festival's or an association's site that shows its events, prices and places left from the ticketing service it sells on, and links to (or embeds) the purchase page.
  • A back office that keeps the attendee list on its side, read incrementally.
  • A door check against the service's own data, from the site's tools.

It is not a box office (a ticket the site sells itself is a product of the marketplace), not a scanning application - a check only reads: no ticket is marked as scanned at the provider; the provider's own application does that - and not an agenda (a link to the ticket page is enough there). It writes nothing to any provider and keeps nothing.

Providers

Package Factory name Catalogue Attendees Check Incremental Widget Credentials
omniticket/weezevent weezevent yes yes among the participants yes - a partner API key and the organizer's token (or login)
omniticket/billetweb billetweb yes yes lookup by barcode yes yes an API user and key
omniticket/helloasso helloasso yes (forms of type Event) yes among the items, never "already used" - yes an OAuth2 client
omniticket/eventbrite eventbrite yes yes among the attendees yes - a private or OAuth token
omniticket/ticketmaster ticketmaster yes (public, Discovery API) - - - - an app's consumer key
Omniticket\Testing\FixedTicketingFactory (in the core) fixed yes yes among the attendees - - none

The columns are each provider's capabilities(), probed in the harness on 2026-10-26. No provider was run against a live account; Weezevent's one public endpoint was (Verified, and not).

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omniticket omniticket/billetweb symfony/http-client

Take the providers the organizer sells on: omniticket/weezevent, omniticket/billetweb, omniticket/helloasso, omniticket/eventbrite, omniticket/ticketmaster.

Requirements

  • PHP 8.2 or later.
  • omnisense/omniticket requires symfony/http-client-contracts (^3.0), interfaces only, and nothing else.
  • Each provider requires omnisense/omniticket (1.x) and symfony/http-client-contracts (^3.0). The application gives the HTTP client - symfony/http-client, or any implementation of Symfony\Contracts\HttpClient\HttpClientInterface, a MockHttpClient in a test - to the factory's constructor. A factory built without one throws InvalidConfigException when the provider is built (The "billetweb" provider needs an HTTP client: give one to BilletwebFactory.).
  • ext-json (always present since PHP 8.0).

Plain PHP

A whole script, Billetweb answered from recorded answers by the core's RecordedHttpClient:

<?php // bare.php

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

use Omniticket\Billetweb\BilletwebFactory;
use Omniticket\Model\EventQuery;
use Omniticket\Registry;
use Omniticket\Testing\RecordedHttpClient;

// Billetweb's answers, recorded; in production: Symfony\Component\HttpClient\HttpClient::create().
$http = RecordedHttpClient::routes([
    'GET /api/events' => '[{"id":"1235","name":"Retro future shock","start":"2030-12-31 20:00:00","end":"2031-01-01 02:00:00","place":"Paris","shop":"https://www.billetweb.fr/shop.php?event=1235"}]',
    'GET /api/event/1235/avail' => '[{"type":"event","id":"1235","avail":"80","quota":"200","total_sales":"120","status":"available"},{"type":"ticket","id":"124","avail":"80","quota":"100","total_sales":"20"},{"type":"ticket","id":"125","avail":"0","quota":"100","total_sales":"100"}]',
    'GET /api/event/1235/tickets' => '[{"id":"124","name":"Plein tarif","price":"25.00"},{"id":"125","name":"Réduit","price":"18.00"}]',
    'GET /api/event/1235/attendees' => '[{"id":"1","barcode":"4475320","firstname":"Jeanne","name":"Dupont","ticket":"Plein tarif","disabled":"0","used":"0","order_paid":"1"}]',
]);

$registry = new Registry([new BilletwebFactory($http)], [
    'box_office' => ['factory' => 'billetweb', 'options' => ['user' => 'demo', 'key' => 'demo']],
]);

foreach ($registry->catalog('box_office')->events(new EventQuery(limit: 20)) as $event) {
    echo $event->start?->format('Y-m-d H:i'), ' ', $event->title, ', ', $event->venue, "\n";
}

$event = $registry->catalog('box_office')->event('1235');
echo $event->status->value, ', ', $event->availability->remaining, " places left\n";
foreach ($event->rates as $rate) {
    printf("  %s: %s %s, %s left\n", $rate->label, $rate->price?->major(), $rate->price?->currency, $rate->availability->remaining ?? '?');
}

$check = $registry->checker('box_office')->check('1235', '4475320');
echo $check->result->value, ' ', $check->attendee?->name(), "\n";
echo count($http->requests), " calls\n";
$ php bare.php
2030-12-31 20:00 Retro future shock, Paris
on_sale, 80 places left
  Plein tarif: 25.00 EUR, 80 left
  Réduit: 18.00 EUR, 0 left
valid Jeanne Dupont
5 calls

(run on 2026-10-26 in the harness, PHP 8.4)

The pieces:

  • a factory per provider (BilletwebFactory, WeezeventFactory...), which takes the HTTP client;
  • the registry, built from the factories and the providers' options, by name; catalog(), attendees() and checker() give a provider as each contract, and throw NotSupportedException for one it lacks (Ticketmaster is a catalogue only) - Capabilities says it beforehand;
  • the models: Event, Rate, Session, Availability, Money (minor units), Attendee, Check (Concepts).

Five calls: the events, then event()'s three (the event, its availability, its rates), then the check's one (Billetweb looks a barcode up). Building a provider calls nothing; events() calls when the iteration starts. Every failure is an Omniticket\Exception\OmniticketException (Errors).

No class of a framework is loaded on the way: Tests/BareTest.php runs the harness's bare script in a process of its own and checks that nothing but the package and the HTTP client contracts was loaded.

In a framework

There is no Omniticket bundle and no provider in omnibridge/symfony or omnibridge/laravel yet: build the Registry once where the framework builds its services - Symfony, Laravel.

Proposer une modification docs/installation.md

Symfony

There is no Omniticket bundle, and omnibridge/symfony has no Omniticket wiring yet: a Symfony application declares the registry as a service of its own. The factories take the application's http_client by autowiring (Symfony\Contracts\HttpClient\HttpClientInterface).

# config/services.yaml
services:
    _defaults:
        autowire: true

    Omniticket\Billetweb\BilletwebFactory: ~
    Omniticket\Ticketmaster\TicketmasterFactory: ~

    Omniticket\Registry:
        arguments:
            $factories:
                - '@Omniticket\Billetweb\BilletwebFactory'
                - '@Omniticket\Ticketmaster\TicketmasterFactory'
            $config:
                box_office: { factory: billetweb, options: { user: '%env(BILLETWEB_USER)%', key: '%env(BILLETWEB_KEY)%' } }
                concerts: { factory: ticketmaster, options: { api_key: '%env(TICKETMASTER_API_KEY)%', country_code: FR, city: Strasbourg } }
# .env.local, or bin/console secrets:set
BILLETWEB_USER=...
BILLETWEB_KEY=...
TICKETMASTER_API_KEY=...

Then inject the registry:

use Omniticket\Model\EventQuery;
use Omniticket\Registry;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class Programme
{
    public function __construct(private readonly Registry $ticketing, private readonly CacheInterface $cache)
    {
    }

    /** @return list<array<string, mixed>> */
    public function upcoming(): array
    {
        return $this->cache->get('programme', function (ItemInterface $item): array {
            $item->expiresAfter(600);

            return array_map(static fn ($e) => $e->toArray(), [...$this->ticketing->catalog('box_office')->events(new EventQuery(limit: 30))]);
        });
    }
}

Nothing is built when the container compiles: a provider is built, and its required options checked, the first time the registry is asked for it. Each provider states rate limits and fair use rules (Capabilities::$limits): cache the catalogue, keep the attendee list on the application's side.

This page was written from the classes' constructors; it was not run in a Symfony kernel.

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel has no Omniticket provider: a service provider of the application builds the registry once, from a config file, with a symfony/http-client client (the providers take a Symfony\Contracts\HttpClient\HttpClientInterface; Laravel's own HTTP client is Guzzle's, which they do not take).

// config/omniticket.php
return [
    'providers' => [
        'festival' => ['factory' => 'weezevent', 'options' => [
            'api_key' => env('WEEZEVENT_API_KEY'),
            'access_token' => env('WEEZEVENT_ACCESS_TOKEN'),
        ]],
    ],
];
// app/Providers/OmniticketServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omniticket\Registry;
use Omniticket\Weezevent\WeezeventFactory;
use Symfony\Component\HttpClient\HttpClient;

final class OmniticketServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, fn (): Registry => new Registry(
            [new WeezeventFactory(HttpClient::create(['timeout' => 20]))],
            config('omniticket.providers'),
        ));
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then:

use Illuminate\Support\Facades\Cache;
use Omniticket\Registry;

$events = Cache::remember('programme', 600, fn () => array_map(
    static fn ($e) => $e->toArray(),
    [...app(Registry::class)->catalog('festival')->events()],
));

Keep arrays in the cache (toArray() on every model). Configuration caching keeps working: the options are plain arrays. This page was written from the registry's API; it was not run in a Laravel application.

Proposer une modification docs/laravel.md

Concepts

Provider and its contracts

Omniticket\ProviderInterface is one ticketing service, configured:

Method Returns
getName(): string the factory's name: weezevent, billetweb, helloasso, eventbrite, ticketmaster, fixed
getTitle(): string Weezevent, Billetweb, HelloAsso, Eventbrite, Ticketmaster, Fixed
capabilities(): Capabilities what it can be asked, its terms and limits; calls nothing

The contracts, each extending ProviderInterface:

Interface Method
EventCatalogInterface events(EventQuery $query = new EventQuery()): iterable<Event> the account's events (or the public ones), upcoming by default; pages fetched as the iteration goes
event(string $id): Event one event whole: rates, sessions, places left when the provider has them; NotFoundException when it has no such event
AttendeeListInterface attendees(string $eventId, ?\DateTimeImmutable $updatedSince = null): iterable<Attendee> every ticket of the event, cancelled and refunded ones included; with $updatedSince, only those created or changed since (NotSupportedException where the provider cannot)
TicketCheckInterface check(string $eventId, string $code): Check a code checked: valid, already used, void, unknown. Reading only: the ticket is never marked as scanned

Capabilities

Omniticket\Capabilities (readonly):

Property Type Default
catalog bool false EventCatalogInterface
attendees bool false AttendeeListInterface
check bool false TicketCheckInterface
incremental bool false attendees() takes $updatedSince
widget bool false its events carry a purchase module to embed (Event::$widgetUrl)
terms list<string> [] the terms whoever runs it must follow (URLs)
limits list<string> [] the rate limits and usage rules the provider states, in words

contracts() lists catalog, attendees, check as it has them; toArray() all of it. Each provider's values are in the index and its own page.

Factory and Config

Omniticket\ProviderFactoryInterface: getName(): string, create(array $options = []): ProviderInterface. Every provider extends Omniticket\ProviderFactory:

  1. createConfig($options): the options in a Config (an ArrayObject), then the factory's populate() sets omniticket.factory_name, omniticket.factory_title, omniticket.required_options and the defaults of the others;
  2. the required options are checked: one missing, '' or [] is an InvalidConfigException listing every missing key (The "billetweb" provider needs: key.);
  3. build($config) constructs the provider. Nothing is called.

Config's readers: get($key, $default), string($key) (null when empty), int(), float(), bool() (a bool, or "1", "true", "yes", "on"), validateNotEmpty($keys).

Registry

Omniticket\Registry:

$registry = new Registry([new BilletwebFactory($http), new TicketmasterFactory($http)], [
    'box_office' => ['factory' => 'billetweb', 'options' => ['user' => '...', 'key' => '...']],
    'concerts' => ['factory' => 'ticketmaster', 'options' => ['api_key' => '...']],
]);
Method Returns
get(string $name): ProviderInterface the provider, built the first time and kept
catalog(string $name): EventCatalogInterface the provider as a catalogue; NotSupportedException when it is not one
attendees(string $name): AttendeeListInterface likewise (The "concerts" provider (Omniticket\Ticketmaster\TicketmasterProvider) does not list attendees.)
checker(string $name): TicketCheckInterface likewise
has(), options(), names(), factories() as in the other families

An unknown name or factory is an InvalidConfigException (No "x" ticketing provider; configured: ..., No "x" factory for the "y" ticketing provider; installed: ...).

Event

Omniticket\Model\Event (readonly). events() fills what a list gives; event() the whole record.

Property Type Default
id string - the provider's id (HelloAsso: the form's slug)
title string -
start, end ?\DateTimeImmutable null
status EventStatus Unknown
url ?string null where a visitor buys a ticket (the provider's page)
venue ?string null the place's name and city, joined
availability Availability unknown the whole event's places
rates list<Rate> []
sessions list<Session> []
priceFrom, priceTo ?Money null a price range, when the provider gives one rather than rates
widgetUrl ?string null the provider's purchase module, to embed in a frame
provider string '' the provider's name

onSale(): status OnSale and not sold out. soldOut(): status SoldOut or the availability sold out. lowestPrice(): the lowest rate price, or the range's lower end. toArray().

EventStatus: Draft (not published), Upcoming (published, sales not open), OnSale, SoldOut, SalesClosed (sales over, the event not cancelled), Ended, Cancelled, Postponed, Unknown (values in snake_case: on_sale...).

EventQuery

Omniticket\Model\EventQuery (readonly): from, to (?\DateTimeImmutable), keyword (?string), past (bool, default false), limit (?int), now (?\DateTimeImmutable, a test's clock; the real one when null).

A provider passes what its API can filter on, and returns $query->filter(...), which applies the whole query to what comes back:

  • not past: an event whose end (else start) is before now is left out;
  • from: an event whose end (else start) is before it is left out;
  • to: an event that starts after it is left out;
  • keyword: kept when the title or the venue contains it, case-insensitive;
  • limit: at most that many matching events; the iteration stops there (no further page asked).

An event without dates passes the date tests.

Rate, Session, Availability

Rate (readonly), one price: id, label, price (?Money; null when the provider gives none, or the price is the buyer's to choose), availability, salesStart, salesEnd, minimum (?Money, the least a buyer may pay when the price is theirs), category (?string, Billetweb's category, Weezevent's category path Categorie 1 > Sous Categorie 1).

Session (readonly), one date of an event sold for several: id, start, end, name, venue, availability.

Availability (readonly), as far as the provider says - an unknown remainder is null, not 0:

remaining, capacity, sold ?int
soldOut, unlimited bool
Availability::of(?int $remaining, ?int $capacity = null, ?int $sold = null) sold out when 0 remains; remaining computed as capacity minus sold (never below 0) when not given
Availability::unlimited(?int $sold = null) a sale without a limit
known() whether the provider said anything: unlimited, sold out, or a remainder

Money

Omniticket\Model\Money (readonly): int $amount in the currency's minor unit - never a float, so 9.99 stays 999 - and an ISO 4217 currency, upper-cased. fromMajor($major, $currency) from units ("12.50", 9.99, 10); major() back ("12.50", "3000" for JPY); exponent($currency) is 0 for BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, PYG, RWF, UGX, VND, VUV, XAF, XOF, XPF and 2 otherwise; isFree(); toArray().

Attendee

Omniticket\Model\Attendee (readonly), one ticket and the person it is for:

Property Type
id string the provider's ticket or participant id
eventId string
code ?string what the ticket's barcode or QR code holds: what the door checks
firstName, lastName, email ?string
rate ?string the rate's name
status AttendeeStatus default Valid
checkedInAt ?\DateTimeImmutable when it was scanned
orderId, sessionId, ticketUrl ?string

name() joins the names; toArray(). AttendeeStatus: Valid (paid or free, not scanned), CheckedIn (scanned), Cancelled, Refunded, Pending (not paid yet).

Check

Omniticket\Model\Check (readonly): code, result (CheckResult), attendee (?Attendee). valid() is result === Valid. CheckResult: Valid, AlreadyUsed (scanned), Void (cancelled, refunded or not paid), Unknown (no ticket of this event holds the code).

  • Check::of($code, ?Attendee): the result from the attendee's status - none Unknown, Valid Valid, CheckedIn AlreadyUsed, Cancelled, Refunded and Pending Void.
  • Check::among(iterable $attendees, $code): the first attendee whose code equals it (compared in constant time) decides: for providers with no lookup by code.

Support\Data and Http\JsonApi

Omniticket\Support\Data reads a decoded answer with types at a dotted path (venue.city.name): raw(), has(), string() (trimmed, null when empty; a bool as "1"/"0"), int(), float(), bool() (true, 1, "1", "true", "yes"), date($path, $timezone) (a UNIX timestamp or a string PHP reads, in $timezone when it names none; null for 0000-00-00... or unreadable), data(), list(), items().

Omniticket\Http\JsonApi($http, $provider)->request($method, $url, $options, $what): Data calls through the injected client and turns every failure into the family's exceptions (Errors), with the provider's own message when the answer carries one (error_description, error.message, message, detail, errors.0.message, errors.0.detail, fault.faultstring, error).

Proposer une modification docs/concepts.md

Events

use Omniticket\Model\EventQuery;

$catalog = $registry->catalog('box_office');

$catalog->events();                                                     // upcoming ones
$catalog->events(new EventQuery(from: new \DateTimeImmutable('first day of next month'), limit: 20));
$catalog->events(new EventQuery(keyword: 'jazz', past: true));

$event = $catalog->event('1235');
foreach ($event->rates as $rate) {
    printf("%s: %s %s, %s left\n", $rate->label, $rate->price?->major() ?? 'free choice', $rate->price?->currency, $rate->availability->remaining ?? '?');
}
echo $event->onSale() ? 'Buy: '.$event->url : $event->status->value;

events() returns an iterable: a provider's calls are made when the iteration starts, and with a limit no page is asked beyond what the limit needs. A failure (a refused key...) is thrown during the iteration. Keep [...$events] or toArray() in a cache: every provider limits its calls.

What each provider reads

Events listed event() Status Places left Widget
Weezevent GET /events (include_closed with past, include_without_sales) /event/{id}/details, /tickets (rates in nested categories), /dates (sessions): three calls listed: sales status 1 on sale, 2 upcoming, else ended or unknown by date; whole: ended, upcoming, sales closed by the sale period, else on sale per rate from its quota (0: no quota) and participants; the event's sold count -
Billetweb GET /api/events (past=1 with past) the event, /avail, /tickets: three calls ended by date, sold out, on sale when Billetweb says available, else unknown (a listed event is unknown or ended: the list has no availability) per event, rate and session; -1 is no limit shop, the purchase page Billetweb's embedding code frames
HelloAsso GET /v5/organizations/{slug}/forms?formTypes=Event, pages of 100 .../forms/Event/{formSlug}/public Draft draft; Disabled, Deleted sales closed; ended, upcoming, sales closed by dates; Public on sale not given widgetFullUrl
Eventbrite GET /organizations/{id}/events/ (time_filter current_future, or all with past; order_by=start_asc; ticket availability and venue expanded), by continuation /events/{id}/, /events/{id}/ticket_classes/ draft, canceled, ended/completed; live/started: sold out, upcoming before sales start, sales closed when no ticket is available, else on sale per ticket class from quantities; sold out from Eventbrite's flag -
Ticketmaster GET /events.json (the configured filters, startDateTime from from or now, endDateTime, keyword, sort=date,asc, 50 a page, never past the 1000th result) /events/{id}.json onsale (upcoming before the public sale), offsale sales closed, cancelled, postponed, rescheduled on sale not given; a price range -

Prices: Billetweb and Weezevent give units in a currency they do not name (the currency option); HelloAsso cents in EUR (a price of 0 with a minAmount is the buyer's to choose: no price, a minimum); Eventbrite currency objects in minor units (a free class is 0); Ticketmaster a price range in units.

Showing a programme

use Omniticket\Model\EventQuery;

$events = [];
foreach ($registry->names() as $name) {
    if ($registry->get($name)->capabilities()->catalog) {
        array_push($events, ...$registry->catalog($name)->events(new EventQuery(limit: 50)));
    }
}
usort($events, static fn ($a, $b) => $a->start <=> $b->start);

foreach ($events as $event) {
    printf("%s  %-40s %s %s\n", $event->start?->format('d/m H:i'), $event->title, $event->lowestPrice()?->major() ?? '', $event->soldOut() ? 'complet' : '');
}

Event::$widgetUrl (Billetweb, HelloAsso) is the provider's purchase module, to put in a frame; Event::$url its page. Ticketmaster's terms ask that purchases go through the event's url.

Proposer une modification docs/usage-events.md

Attendees and checks

Attendees

$list = $registry->attendees('box_office');

// The first read: every ticket, cancelled and refunded ones included.
foreach ($list->attendees('1235') as $attendee) {
    $store->save($attendee->toArray());
}

// Then only what changed, where the provider allows it (Capabilities::$incremental).
$since = $store->lastRead('1235');
foreach ($list->attendees('1235', $since) as $attendee) {
    $store->save($attendee->toArray());
}

The providers ask for it - Billetweb and Weezevent in so many words: keep the list on the application's side and read it incrementally.

Call Incremental Code Status
Weezevent GET /participant/list (include_deleted, include_unpaid), plus /tickets once per event for the rates' names last_update, in Weezevent's zone barcode deleted: cancelled; refund: refunded; not paid: pending; control_status.status 1: checked in (its scan date)
Billetweb GET /api/event/{id}/attendees?disabled=1 last_update (a timestamp) barcode disabled: cancelled when the order was paid, else refunded; used 1 or 2 (scanned, gone out): checked in (its date); 3 (expired): cancelled; order not paid: pending
HelloAsso GET .../forms/Event/{formSlug}/items?withDetails=true, pages of 100 NotSupportedException: the items have no such filter qrCode a refunded payment: refunded; Processed, Registered: valid; Waiting, Unknown or none: pending; anything else: cancelled. No scan state
Eventbrite GET /events/{id}/attendees/, by continuation changed_since (UTC) the first barcode refunded; cancelled or Not Attending: cancelled; checked in or barcode used: checked in (the barcode's date); Unpaid: pending
Ticketmaster - (NotSupportedException from the registry)

Checking a ticket at the door

use Omniticket\Model\CheckResult;

$check = $registry->checker('box_office')->check('1235', $scannedCode);
echo match ($check->result) {
    CheckResult::Valid => 'OK '.$check->attendee?->name().' - '.$check->attendee?->rate,
    CheckResult::AlreadyUsed => 'Already scanned at '.$check->attendee?->checkedInAt?->format('H:i'),
    CheckResult::Void => 'Cancelled, refunded or not paid',
    CheckResult::Unknown => 'Not a ticket of this event',
};

A check reads only: the ticket is not marked as scanned at the provider (the provider's own scanning application does that; Billetweb's check POST, which would, is not used). Two checks of the same code both answer Valid until the provider's own application scans it.

How the code is found Calls per check
Billetweb a lookup by barcode (attendees?barcode=) one
Weezevent the event's participants read, the code looked for among them two (the rates' names kept after the first)
Eventbrite the event's attendees read, every page one per page
HelloAsso the event's items read; never AlreadyUsed (no scan state) one per page, plus a token every 30 minutes

An application that checks many tickets keeps the list itself - attendees() with $updatedSince, then Check::among($attendees, $code) locally - rather than reading the whole list at every scan: Billetweb's attendee calls are limited to 10 a minute, Eventbrite's to 2,000 an hour.

Proposer une modification docs/usage-attendees.md

Configuration reference

The registry

new Registry(iterable $factories, array $config);
Key Type Required
<name> string - the name the application gives the provider: box_office, concerts...
<name>.factory string yes weezevent, billetweb, helloasso, eventbrite, ticketmaster, fixed
<name>.options array no ([]) the provider's options, below

Options are taken as given: a required option given as null, '' or [] is reported missing; an optional one given as '' reads as not set. Every provider's factory takes the HTTP client in its constructor (?HttpClientInterface $http = null); without one, building the provider is an InvalidConfigException.

weezevent

Option Type Default Required
api_key string - yes the partner API key Weezevent gives (access is limited to registered partners), sent as api_key
access_token string none this, or username and password the organizer's token, persistent: POST /auth/access_token once and keep it
username, password string none (see above) the organizer's login, exchanged for a token at the first call and kept for the provider's life
timezone string Europe/Paris no the zone Weezevent's dates are in: it gives local times without one
currency string EUR no the currency of its prices: its answers do not name it
url string https://api.weezevent.com no

Neither a token nor a login: InvalidConfigException (The "weezevent" provider needs: access_token, or username and password.).

billetweb

Option Type Default Required
user string - yes the API user (Billetweb's back office, "API")
key string - yes the API key; sent with the user as HTTP Basic. A read-only key is all this package needs
timezone string Europe/Paris no the zone Billetweb's dates are in
currency string EUR no the currency of its prices
url string https://www.billetweb.fr no

helloasso

Option Type Default Required
client_id, client_secret string - yes the API client of the association's back office ("Intégrations et API"): a token from POST /oauth2/token, client credentials, kept until a minute before it expires (30 minutes)
organization string - yes the association's slug, as in https://www.helloasso.com/associations/<slug>
url string https://api.helloasso.com no the sandbox: https://api.helloasso-sandbox.com

Prices are read in EUR.

eventbrite

Option Type Default Required
token string - yes a private token (account settings, "Developer links", "API keys") or an OAuth token, sent as a bearer
organization string - yes the organization's id, whose events are listed (GET /users/me/organizations/ gives it)
url string https://www.eventbriteapi.com/v3 no

ticketmaster

Option Type Default Required
api_key string - yes the consumer key of an app on developer.ticketmaster.com, sent as apikey
country_code string none no ISO 3166-1 alpha-2: every search narrowed to it (countryCode)
city string none no city
venue_id string none no venueId: a venue's events
attraction_id string none no attractionId: an artist's events
classification_name string none no classificationName: music, sports...
locale string none no locale
url string https://app.ticketmaster.com/discovery/v2 no

fixed

Omniticket\Testing\FixedTicketingFactory(array $events = [], array $attendees = []): no option; the events and the attendees (by event id) are given to the constructor (Testing).

Proposer une modification docs/configuration.md

Errors

Every exception Omniticket throws implements Omniticket\Exception\OmniticketException (a \Throwable):

Exception Extends When
InvalidConfigException \InvalidArgumentException a provider not configured, a factory not installed, a required option missing, no HTTP client given to a factory, Weezevent without a token or a login
NotSupportedException \LogicException the registry's typed getter asked of a provider without that contract; an incremental read a provider cannot do (HelloAsso). Before any call
ProviderException \RuntimeException the provider answered, and not with what was asked: a status of 400 or more, an answer that is not JSON, no token in a token answer. Its code is the HTTP status
InvalidKeyException ProviderException 401 or 403: a wrong or revoked key, a token past its time, a scope not granted
NotFoundException ProviderException 404, or an event the provider does not list: nothing under that id
UnreachableException \RuntimeException the provider could not be reached: a network error, a timeout; a copy of the HTTP client's exception is the previous one (no secret in it)

Messages, as JsonApi and the providers write them (run on recorded answers on 2026-10-26):

NotSupportedException (0): The "concerts" provider (Omniticket\Ticketmaster\TicketmasterProvider) does not list attendees.
NotFoundException (404): Ticketmaster has no event "9": Resource not found with provided criteria (locale=en-us, id=9)
InvalidKeyException (401): Billetweb refused the credentials (401) for the events: invalid key
NotSupportedException (0): The "helloasso" provider (Omniticket\HelloAsso\HelloAssoProvider) does not read attendees changed since a moment (its items have no such filter).
ProviderException (429): Eventbrite answered 429 for event "1": Hourly rate limit has been reached for this token.
InvalidConfigException (0): The "weezevent" provider needs: access_token, or username and password.
InvalidConfigException (0): The "weezevent" provider needs an HTTP client: give one to WeezeventFactory.
InvalidConfigException (0): The "billetweb" provider needs: key.
ProviderException (200): Example answered 200 for it: an answer that is not JSON

The provider's own message is taken from the answer under the names APIs give it (Concepts). A 429 (a rate limit) is a ProviderException with code 429. An events() listing throws while it is iterated, not when it is called.

No secret in a failure

An UnreachableException keeps the request's secrets out. The HTTP client's message quotes the URL called: its key parameters (Ticketmaster's apikey), a password in its user part and every secret of the request's options (a bearer token, a key in a header) are masked (apikey=***, Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message), and no trace of the chain keeps its arguments, where the query and headers stood: a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key. Nor does an error answered (a refused key, nothing there, a server error, a body that is not JSON): every exception JsonApi::request() throws leaves its trace's arguments out.

What to catch where

Where Catch
a programme page UnreachableException, ProviderException: show the last cached list; InvalidKeyException: alert someone
the door the same: fall back on the attendee list kept on the application's side (Check::among())
an attendee sync ProviderException with code 429: wait, the providers count calls
a list over every provider check capabilities() first; NotSupportedException is a programming error
building the registry, a deploy InvalidConfigException: a configuration error, to fix

Proposer une modification docs/errors.md

Testing

The fixed provider

Omniticket\Testing\FixedTicketing answers from what it was given, every contract, no network: an application's tests, a demo, the harness's bare script. FixedTicketingFactory gives it to a registry under the name fixed:

use Omniticket\Model\Attendee;
use Omniticket\Model\AttendeeStatus;
use Omniticket\Model\Availability;
use Omniticket\Model\Event;
use Omniticket\Model\EventQuery;
use Omniticket\Model\EventStatus;
use Omniticket\Model\Money;
use Omniticket\Model\Rate;
use Omniticket\Registry;
use Omniticket\Testing\FixedTicketingFactory;

$events = [
    new Event('1235', 'Retro future shock', new \DateTimeImmutable('2026-12-31 20:00:00'), status: EventStatus::OnSale,
        availability: Availability::of(null, 200, 120),
        rates: [new Rate('124', 'Plein tarif', Money::fromMajor('25.00', 'EUR'), Availability::of(90, 100))]),
];
$attendees = ['1235' => [
    new Attendee('1', '1235', '4475320', 'Jeanne', 'Dupont'),
    new Attendee('2', '1235', '4475321', 'Paul', 'Martin', status: AttendeeStatus::CheckedIn),
]];

$registry = new Registry([new FixedTicketingFactory($events, $attendees)], ['box_office' => ['factory' => 'fixed']]);

$registry->catalog('box_office')->events(new EventQuery(now: new \DateTimeImmutable('2026-11-01')));   // the event
$registry->checker('box_office')->check('1235', '4475321')->result;                                     // CheckResult::AlreadyUsed

events() applies the query to the events given (give now for a fixed clock); event() throws NotFoundException for an id not given; check() is Check::among() over the attendees. It is not incremental (capabilities()->incremental false): attendees($eventId, $updatedSince) with a date is a NotSupportedException, as on a provider that cannot read since a moment. The application keeps the name it uses; only the factory changes.

The recorded HTTP client

Omniticket\Testing\RecordedHttpClient implements Symfony's HttpClientInterface, answers from what it was given and keeps every request ($requests: method, URL, options): a provider's calls checked with no network.

use Omniticket\Testing\RecordedHttpClient;

$http = RecordedHttpClient::routes([
    'GET /api/events' => '[{"id":"1235","name":"Retro future shock","start":"2030-12-31 20:00:00"}]',
    'GET /api/private' => [401, '{"error":"invalid key"}'],
]);

routes(): the first route whose method matches and whose path ends the URL's path (the query is ignored) answers its body (status 200), or [status, body]; anything not recorded is a 404 ({"error":"not recorded: GET /path"}). new RecordedHttpClient(fn ($method, $url, $options) => [status, body, headers]) answers anything. It does not stream. The installation script runs Billetweb on it.

The load check

Omniticket\Testing\LoadCheck::run($root, $namespace, $autoload) loads every class of a package in a PHP process of its own (bin/load-all) and reports what came from outside: classes of a framework, an HTTP client or an application (LoadCheck::FOREIGN; Symfony's HTTP client and service contracts are allowed), and files of packages it does not require (outside()). Every package of the family runs it in its BareTest.

Live checks

docker compose run --rm omniticket live runs the live group: only what needs no account - Weezevent's GET /event/categories (WeezeventProvider::eventCategories()).

Proposer une modification docs/testing.md

The Docker harness

docker/ composes this core and every provider (docker/harness/plugins.php) into one installation, PHP 8.4, in the image omnisense/omniticket-harness. docker/harness/setup.php writes the harness's composer.json and phpunit.xml into a volume, and the entrypoint installs them when they change.

cd docker
docker compose run --rm omniticket test       # every package's tests, no network (the live group left out)
docker compose run --rm omniticket live       # the checks that need no account (Weezevent's public endpoint)
docker compose run --rm omniticket bare       # the bare script: plain PHP, and what PHP loaded
docker compose run --rm omniticket phpstan    # PHPStan at the highest level, each package
docker compose run --rm -v "$PWD/scratch:/scratch" omniticket php /scratch/x.php   # a script of your own (autoloader: /harness/vendor/autoload.php)
docker compose down -v                        # the harness's volume gone

With no command the entrypoint runs test; composer, sh and php run as they are. Providers come from GitHub (branch 1.x, github.com/glitchr-omnisense/omniticket-<slug>) unless OMNITICKET_PLUGINS (in docker/.env) points at a directory holding their checkouts - ../.. when this core sits in the workspace beside them, as docker/.env sets it.

Bare

docker/harness/bin/bare uses Omniticket in plain PHP - no bundle, no container, no framework: a registry with the fixed provider, the upcoming events listed (a past one left out, a keyword), one read whole, its attendees, four codes checked at the door; then a JSON API answered through the recorded HTTP client - an answer read with types, a refused key and a missing event turned into the family's exceptions. It prints what happened and every class and file PHP loaded, as JSON; run on 2026-10-26, without the event and loaded parts:

"upcoming": ["1235", "1236"],
"keyword": ["1236"],
"onSale": true,
"lowestPrice": "18.00",
"attendees": [["Jeanne Dupont", "valid"], ["Paul Martin", "checked_in"], ["Ana Lima", "refunded"]],
"checks": {"4475320": "valid", "4475321": "already_used", "4475322": "void", "0000000": "unknown"},
"api": ["Retro future shock", "2026-12-31T20:00:00+01:00"],
"errors": {"/api/private": [true, "Example refused the credentials (401) for it: invalid key"],
           "/api/event/9": [true, "Example has no it: not recorded: GET /api/event/9"]}

Tests/BareTest.php runs it in a process of its own and checks that nothing outside the package was loaded but Symfony's HTTP client contracts. Each provider's BareTest runs Testing\LoadCheck on its own classes.

Proposer une modification docs/harness.md

Writing a provider

A provider is a package omniticket/<name> with a factory and a provider class:

  • the factory extends Omniticket\ProviderFactory: populate() names it, lists its required options and their defaults; build() returns the provider. The HTTP client comes through the factory's constructor, never created inside.
  • the provider implements the contracts it can answer - EventCatalogInterface, AttendeeListInterface, TicketCheckInterface - and says so in capabilities(), with the terms and the rate limits the service states.
  • calls go through Http\JsonApi (failures become the family's exceptions) and answers are read with Support\Data; a listing passes what the API can filter on and returns $query->filter(...), so the core applies the whole query.
  • a check reads: it never marks a ticket as scanned. Without a lookup by code, Check::among() over the attendees.
  • an Attendee per ticket, cancelled and refunded ones included (their status says so); money in minor units (Money::fromMajor() when the API gives units); an unknown remainder is null.
  • tests run in the core's harness (docker/harness/plugins.php lists the providers) with recorded answers - from the service's documentation when it publishes examples, said so in the test - and a BareTest (Testing\LoadCheck); a LiveTest (group live) only for what needs no account.

A skeleton

namespace Omniticket\Example;

use Omniticket\Capabilities;
use Omniticket\Config;
use Omniticket\EventCatalogInterface;
use Omniticket\Exception\InvalidConfigException;
use Omniticket\Http\JsonApi;
use Omniticket\Model\Event;
use Omniticket\Model\EventQuery;
use Omniticket\ProviderFactory;
use Omniticket\ProviderInterface;
use Omniticket\Support\Data;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ExampleFactory extends ProviderFactory
{
    public function __construct(private readonly ?HttpClientInterface $http = null)
    {
    }

    protected function populate(Config $c): void
    {
        $c->defaults(['omniticket.factory_name' => 'example', 'omniticket.factory_title' => 'Example', 'omniticket.required_options' => ['api_key']]);
    }

    protected function build(Config $c): ProviderInterface
    {
        return new ExampleProvider($this->http ?? throw new InvalidConfigException('The "example" provider needs an HTTP client.'), (string) $c->string('api_key'));
    }
}

final class ExampleProvider implements EventCatalogInterface
{
    private readonly JsonApi $api;

    public function __construct(HttpClientInterface $http, #[\SensitiveParameter] private readonly string $apiKey)
    {
        $this->api = new JsonApi($http, 'Example');
    }

    public function getName(): string { return 'example'; }

    public function getTitle(): string { return 'Example'; }

    public function capabilities(): Capabilities
    {
        return new Capabilities(catalog: true, terms: ['https://example.org/terms'], limits: ['what the service states']);
    }

    public function events(EventQuery $query = new EventQuery()): iterable
    {
        $answer = $this->api->request('GET', 'https://api.example.org/events', ['query' => ['key' => $this->apiKey]], 'the events');

        return $query->filter(array_map($this->toEvent(...), $answer->list('events')));
    }

    public function event(string $id): Event
    {
        return $this->toEvent($this->api->request('GET', 'https://api.example.org/events/'.rawurlencode($id), ['query' => ['key' => $this->apiKey]], 'event "'.$id.'"'));
    }

    private function toEvent(Data $line): Event
    {
        return new Event((string) $line->string('id'), $line->string('title') ?? '', $line->date('start'), provider: 'example');
    }
}

The address and the fields are placeholders. Each provider's own manual lists its calls, the mapping of its statuses and what it does not read.

What the five providers keep to

  • The HTTP client injected, never created: a factory without one refuses to build.
  • Credentials as #[\SensitiveParameter] constructor arguments.
  • Listings returned through $query->filter(); where the API pages (HelloAsso, Eventbrite, Ticketmaster), pages fetched as the iteration goes (a generator), so a limit stops the paging.
  • Dates read in the service's zone when it gives local times (Billetweb, Weezevent: the timezone option); prices in the currency it names, or the currency option.
  • Nothing written: no check marks a ticket as scanned.

Proposer une modification 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, the live group left out, no network: 50 tests, 200 assertions, green.

  • The core: the models (money in minor units, availability, statuses, checks), the query's filter, the registry and its errors, JsonApi's mapping of statuses to exceptions; in plain PHP, events listed, read, attendees and codes checked through a registry and a recorded JSON API, loading nothing but omniticket and Symfony's HTTP client contracts (BareTest).
  • Each provider, every class loading with its requirements alone (BareTest, LoadCheck), and: Billetweb's and Weezevent's calls against the examples of their own documentation, as published; HelloAsso's, Eventbrite's and Ticketmaster's against answers composed from their documented fields, not recorded - each provider's README says which. Events and their statuses, one event whole (rates, sessions, places left), attendees (incrementally where the provider allows), checks valid, already used, refunded and unknown, a refused key.

The examples of this manual (the installation script on Billetweb, the error messages, the testing page, the bare script) were run in the harness on 2026-10-26.

What was run live

Per the READMEs, on 2026-10-24 (docker compose run --rm omniticket live): Weezevent's public endpoint, GET /event/categories, no key - its host and its JSON. Not run again for this manual.

What is not verified

  • Every call that needs an account or a key: all five providers' events, attendees and checks.
  • Whether Weezevent cuts a very large participant list without paging (max and page are documented without their base).
  • Eventbrite's response shapes were not re-read on 2026-10-24, nor its rate figures.
  • The Symfony and Laravel pages were written from the classes' constructors, not run in an application.

Responsibility

omnisense/omniticket and its omniticket/* providers are unofficial clients, affiliated with none of the services they reach. The MIT licence covers the code only and grants no right to any service or its data; whoever runs them complies with each service's terms and limits (named in each provider's README and in Capabilities::$terms and $limits). Attendees' names and e-mails are personal data: keep and show them as the organizer's own rules and the law allow.

Proposer une modification docs/verified.md