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
- Installation: requirements, a first script in plain PHP.
- Symfony and Laravel: no bridge yet; the registry as a service, by hand.
- Concepts: providers and their contracts, capabilities, factories, the registry, every model.
- Usage: events and attendees and checks.
- Configuration reference: every provider's options.
- Errors.
- Testing: the fixed provider, the recorded HTTP client, the load check.
- The Docker harness.
- Writing a provider.
- Verified, and not.
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/omniticketrequiressymfony/http-client-contracts(^3.0), interfaces only, and nothing else.- Each provider requires
omnisense/omniticket(1.x) andsymfony/http-client-contracts(^3.0). The application gives the HTTP client -symfony/http-client, or any implementation ofSymfony\Contracts\HttpClient\HttpClientInterface, aMockHttpClientin a test - to the factory's constructor. A factory built without one throwsInvalidConfigExceptionwhen 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()andchecker()give a provider as each contract, and throwNotSupportedExceptionfor one it lacks (Ticketmaster is a catalogue only) -Capabilitiessays 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:
createConfig($options): the options in aConfig(anArrayObject), then the factory'spopulate()setsomniticket.factory_name,omniticket.factory_title,omniticket.required_optionsand the defaults of the others;- the required options are checked: one missing,
''or[]is anInvalidConfigExceptionlisting every missing key (The "billetweb" provider needs: key.); 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 - noneUnknown,ValidValid,CheckedInAlreadyUsed,Cancelled,RefundedandPendingVoid.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 incapabilities(), 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 withSupport\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
Attendeeper 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.phplists the providers) with recorded answers - from the service's documentation when it publishes examples, said so in the test - and aBareTest(Testing\LoadCheck); aLiveTest(grouplive) 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
timezoneoption); prices in the currency it names, or thecurrencyoption. - 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 (
maxandpageare 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.