flairuk / laravel-all-aboard
A Laravel client for the All Aboard rail API: European train journeys, offers, bookings, orders, rail passes and refunds.
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/collections: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0|^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel All Aboard — A Laravel 12 and 13 client for the All Aboard API, for travel sites and agents that search, price and book European train journeys and rail passes.
- The whole booking flow. Covers locations, journey search, offers (including round-trip fares), bookings, orders, payment-gateway checkout, tickets, rail passes, travel cards, refunds and access tokens.
- No GraphQL to write. Every method sends a query with a sensible set of fields, checked against the live schema. Pass your own fields when you need more or less.
- Pagination built in. Locations and orders can be walked with
->lazy(), which fetches the next page only when you reach it. - Safe retries. Queries are retried on connection errors and 5xx responses. Bookings, orders, payments and refunds never are, so nothing is booked or charged twice.
- Real errors. GraphQL errors throw exceptions carrying All Aboard's error codes (
NO_TICKETS,PRICE_CHANGED…), their arguments and the request id to quote to their support.
This is an unofficial package. It is not affiliated with or endorsed by All Aboard. You need your own All Aboard account and API key.
📦 Installation · 🚀 Usage · 🚆 Booking flow · 🎫 Rail passes · ⚠️ Errors · ⚙️ Configuration
📦 Installation
composer require flairuk/laravel-all-aboard php artisan all-aboard:install
all-aboard:install publishes config/all-aboard.php and adds these keys to .env and .env.example:
ALL_ABOARD_API_KEY= # from the All Aboard Dashboard ALL_ABOARD_TEST=true # use the test environment until you go live ALL_ABOARD_CURRENCY= # optional default currency, e.g. GBP (All Aboard uses EUR otherwise)
Then check the connection:
php artisan all-aboard:status
The test environment is a separate gateway and needs a key with the test scope. Keys are given scopes in the Dashboard: live or test for access at all, orders:read to read orders, and payments:wallet, payments:stripe or payments:invoice for each way of paying. See API Scopes.
🚀 Usage
use FLAIRUK\AllAboard\Facades\AllAboard;
You can also type-hint FLAIRUK\AllAboard\AllAboard to have it injected.
Every call returns a Response wrapping the operation's result:
$response->data(); // the result, e.g. the booking or the list of journeys $response->get('totalPrice.amount'); $response->toArray(); // the whole GraphQL body foreach ($response as $item) { /* journeys, offers, locations… */ } count($response); $response['id']; // fields of a single result $response[0]['id']; // entries of a list
Prices are ['amount' => 13400, 'currency' => 'EUR'], with the amount in minor units (cents, pence).
Locations
Journeys are searched between location uids. All Aboard recommends keeping your own copy of the locations and re-syncing it on a schedule:
foreach (AllAboard::locations()->all() as $location) { // ['uid' => 'Sb0ISveC', 'name' => 'London', 'countryCode' => 'GB', 'isMeta' => true, 'coordinates' => [...]] Station::updateOrCreate(['uid' => $location['uid']], [...]); }
Remove any you stored that the walk no longer returns. For autocomplete, you can also ask the API directly:
AllAboard::locations()->search('koln'); // finds Köln; most relevant first AllAboard::locations()->near(48.8566, 2.3522, radius: 10_000); AllAboard::locations()->list(['countryCode' => 'FR', 'isMeta' => true])->lazy();
A meta location (isMeta: true) is a city rather than one station: "Paris" rather than "Paris Gare de Lyon". Searching from it lets the API pick the right station for the route, so prefer meta locations and list them first.
Finding journeys
Search the whole trip and let the API route it:
$journeys = AllAboard::journeys()->search('Sb0ISveC', 'p6fERure', '2026-11-01'); // London to Rome foreach ($journeys as $journey) { $journey['id']; $journey['__typename']; // SmartJourney, NonStopJourney or BlueprintJourney $journey['itinerary']; // SegmentCollections (trains) and Stopovers }
Options follow the journeys query:
AllAboard::journeys()->search($origin, $destination, now()->addMonth(), [ 'filter' => ['types' => ['NON_STOP']], // SMART (default), NON_STOP or BLUEPRINT 'via' => [['uid' => 'ObB7ATsZ', 'duration' => new DateInterval('P2D')]], // two nights in Zürich 'passengers' => ['adult'], 'currency' => 'GBP', ]); AllAboard::journeys()->ratings([$journeyA, $journeyB]); // [0.92, 0.71]: 1 is best AllAboard::journeys()->blueprint($blueprintId, '2026-11-01');
Getting offers
An offer is a way to travel a journey: a price, a flexibility and a class. Pricing waits on the train operators and can take up to 30 seconds:
$offer = AllAboard::journeys()->offer($journeyId, [ ['type' => 'ADULT'], ['type' => 'YOUTH', 'age' => 22], // youth and senior passengers need an age or birthDate ]); foreach ($offer['itinerary'] as $part) { if ($part['__typename'] === 'SegmentCollection') { $part['status']; // SUCCESS, or why this part couldn't be priced $part['offers']; // id, price, parts (flexibility, classes, conditions)… } }
Passengers can be arrays, or shorthand: 'adult' for a type, 30 for an age. Up to five per request.
All Aboard has no hard look-to-book limit, but asks that you only price journeys a customer is likely to book.
Round trips are priced in one call, so operators can quote genuine return fares:
$offer = AllAboard::journeys()->combinedOffer($outboundJourneyId, $inboundJourneyId, ['adult']); $offer->get('outbound.itinerary'); $offer->get('inbound.itinerary');
Offers that share a memberOf.id are one ticket (a return fare shows as a full price one way and a zero-priced companion the other). Book all of them together or none.
Travel cards
Railcards, discount cards and passes such as Interrail change what a passenger pays:
AllAboard::travelCards()->options(); // what your account can use (cached) AllAboard::journeys()->offer($journeyId, [ ['type' => 'ADULT', 'travelCards' => [['code' => 'aatc_uk_16_25']]], ['type' => 'ADULT'], ]);
Send exactly the same passengers and cards when you create the booking. Cards can't be changed afterwards.
Anything else
For operations or fields without a wrapper, run GraphQL directly. Authentication, the session header and error handling still apply:
AllAboard::query('query PlaceMap($offerId: ID!, $partId: ID!) { placeMapForPart(offerId: $offerId, partId: $partId) { __typename } }', [ 'offerId' => $offerId, 'partId' => $partId, ]); AllAboard::mutate('mutation ...', $variables); // never retried AllAboard::node($id, '... on Offer { id price { amount currency } }');
Every resource method takes a fields argument to replace its default selection. The defaults are constants on FLAIRUK\AllAboard\Fields if you want to build on them:
use FLAIRUK\AllAboard\Fields; AllAboard::journeys()->search($from, $to, $date, ['passengers' => ['adult']], fields: Fields::JOURNEY.' itinerary { ... on SegmentCollection { availability { status priceFrom { '.Fields::MONEY.' } } } }'); AllAboard::orders()->find($orderId, fields: 'id status reference');
🚆 Booking flow
Create a booking from the offers, add the passengers' details, create an order to pre-book the tickets, then pay:
$booking = AllAboard::bookings()->create([$offerId], [['type' => 'ADULT'], ['type' => 'YOUTH', 'age' => 22]]); $booking->get('requirements'); // which details the operators need: email, tel, passportNumber… $booking->get('expiresAt'); // turn it into an order before then AllAboard::bookings()->updatePassengers($booking['id'], [ [ 'id' => $booking->get('passengers.0.id'), 'firstName' => 'Jane', 'lastName' => 'Doe', 'email' => 'jane@example.com', 'tel' => '+441234567890', 'isContactPerson' => true, // one passenger must be the contact, with email and tel ], ['id' => $booking->get('passengers.1.id'), 'firstName' => 'Sam', 'lastName' => 'Doe', 'birthDate' => '2004-03-09'], ]); $order = AllAboard::orders()->create($booking['id'], ['customer_id' => $customer->id]); // up to 30 seconds
Then, depending on how your cost center pays:
// Wallet or invoice: complete it yourself AllAboard::orders()->finalize($order['id']); // Payment gateway: send the customer to All Aboard's hosted payment page $payment = AllAboard::orders()->createPayment( $order['id'], successUrl: route('trains.done', $order['id']), cancelUrl: route('trains.checkout'), language: 'en', ); return redirect($payment['url']); // the order is finalized automatically once they pay
create(), finalize() and createPayment() are never retried automatically. If one of them times out, check with orders()->find() before trying again.
Changing the booking
AllAboard::bookings()->selectOffers($bookingId, [$flexibleOfferId]); // a different fare AllAboard::bookings()->selectPlaceProperties($bookingId, $partId, [$propertyId => $optionId]); // window seat, lower berth… AllAboard::bookings()->setTicketDelivery($offerId, 'TICKET_ON_DEPARTURE'); // where the offer allows it AllAboard::bookings()->find($bookingId);
To bill passengers to a cost center other than your default, set it on them when creating the booking:
AllAboard::agent()->costCenters(); // [['id' => ..., 'name' => ..., 'default' => true, 'paymentMethod' => 'WALLET'], …] AllAboard::bookings()->create([$offerId], [['type' => 'ADULT', 'costCenter' => ['id' => $costCenterId]]]);
Tickets
Tickets are issued after finalizing, usually within 15 minutes. Poll the order, for example from a queued job:
$order = AllAboard::orders()->find($orderId); $order['status']; // PENDING, CONFIRMED, FAILED, REFUNDED… $tickets = AllAboard::orders()->tickets($orderId); foreach ($tickets as $ticket) { match ($ticket['__typename']) { 'PdfTicket', 'FileResource' => $ticket['url'], 'CheckInResource' => $ticket['url'], // the passenger checks in online 'TicketOnDeparture' => $ticket['reference'], // collect from a station machine 'PassCode' => $ticket['code'], // activate in the pass provider's app default => null, }; }
tickets() is empty until issuance completes. List orders with AllAboard::orders()->list()->lazy() (needs orders:read).
Refunds
$items = AllAboard::refunds()->refundable($orderId); // what can be refunded, and for how much AllAboard::refunds()->refund($orderId, [$items[0]['id']]); AllAboard::refunds()->list($orderId); // each refund's state: PENDING, SUCCEEDED or ERROR
Public pages and access tokens
Never put an API key with orders:read on a page the public can open. Mint an access token for the one order instead. It reaches nothing else and expires on its own:
$token = AllAboard::accessTokens()->create($orderId, ['READ', 'WRITE'], ttlSeconds: 900); $token['token']; // the secret, returned only this once
Pass it to the Refund Manager embed, or use it from PHP:
AllAboard::withAccessToken($token['token'])->orders()->find($orderId); AllAboard::accessTokens()->revoke($token['id']);
🎫 Rail passes
All Aboard finds the cheapest combination of passes (a bundle) covering every passenger:
$bundles = AllAboard::passes()->bundles( [['type' => 'ADULT', 'nationality' => 'GB', 'countryResidence' => 'GB']], ['product' => 'interrail-global-pass', 'class' => '1stclass'], ); AllAboard::passes()->forJourneyOffer($journeyOfferId, ['adult']); // passes valid for a priced journey AllAboard::passes()->campaigns(); // codes to pass as 'campaignCode'
Booking a pass follows the same flow as tickets:
$booking = AllAboard::bookings()->createForPassBundle($bundles[0]['id'], [['birthDate' => '1980-02-15']]); // updatePassengers(), orders()->create(), then finalize() or createPayment()
Each passenger's pass code appears in orders()->tickets() once issued.
⚠️ Errors and rate limits
All Aboard reports errors in the GraphQL response, each with a code:
use FLAIRUK\AllAboard\Exceptions\AllAboardException; use FLAIRUK\AllAboard\Exceptions\AuthenticationException; use FLAIRUK\AllAboard\Exceptions\RateLimitException; use FLAIRUK\AllAboard\Exceptions\TimeoutException; try { AllAboard::bookings()->create($offerIds, $passengers); } catch (RateLimitException $e) { // wait $e->retryAfter() seconds } catch (AuthenticationException $e) { // missing or rejected key, or a key without the scope this needs } catch (TimeoutException $e) { // an operator didn't answer in time: the work may still have completed, so re-read before retrying } catch (AllAboardException $e) { if ($e->hasCode('NO_TICKETS', 'PRICE_CHANGED', 'OFFER_SET_UNAVAILABLE')) { // get a fresh offer and try again } if ($e->hasCode('TRAVEL_CARD_FIELD_REQUIRED')) { $e->args('TRAVEL_CARD_FIELD_REQUIRED')['field']; // e.g. "identifier": ask for it and book again } $e->codes(); // e.g. ['NO_TICKETS'] $e->errors; // the raw GraphQL errors $e->requestId; // quote this to All Aboard support $e->data(); // any partial data that came back }
The full list is in Error Codes. In queued jobs, release the job on a rate limit:
catch (RateLimitException $e) { $this->release($e->retryAfter()); }
AllAboard::agent()->rateLimits() shows each of your limits, how much is left and when it resets.
⚙️ Configuration
| Key | Env | Default |
|---|---|---|
api_key |
ALL_ABOARD_API_KEY |
|
test |
ALL_ABOARD_TEST |
false |
base_url |
ALL_ABOARD_BASE_URL |
built from test |
session |
ALL_ABOARD_SESSION |
from the Laravel session |
currency |
ALL_ABOARD_CURRENCY |
EUR, All Aboard's default |
timeout |
ALL_ABOARD_TIMEOUT |
90 seconds |
retry |
2 retries, 500 ms apart (queries only) | |
cache.store |
ALL_ABOARD_CACHE_STORE |
the default cache store |
cache.ttl |
24 hours; null turns caching off |
Every request carries a session header, which All Aboard uses to trace one customer's booking flow. By default it is a hash of the Laravel session id, so a customer's requests share one; outside a web request (in a queued job, say) each client gets a random one. Set it yourself to tie a job back to the customer:
AllAboard::forSession($booking->session_id)->orders()->finalize($orderId);
Scope a single call differently without changing the defaults:
AllAboard::test()->journeys()->search($from, $to, $date); AllAboard::withAccessToken($token)->refunds()->list($orderId);
Streaming
All Aboard can stream journey offers over WebSockets as each part is priced. This package uses plain HTTP, which waits for the complete result. If you want prices to appear as they arrive, use the embeds or a graphql-ws client in the browser.
🧪 Testing
composer test
The client uses Laravel's HTTP client, so Http::fake() works in your own tests:
Http::fake([ 'api-gateway.allaboard.eu/*' => Http::response(['data' => ['journeys' => []]]), ]);
📄 License
The MIT License (MIT). See LICENSE for details.
All Aboard is a trademark of its owner. This package is not affiliated with or endorsed by All Aboard.