Search by

flairuk / laravel-all-aboard

ijeffro

A Laravel client for the All Aboard rail API: European train journeys, offers, bookings, orders, rail passes and refunds.

Package info

github.com/FLAIRUK/laravel-all-aboard

pkg:composer/flairuk/laravel-all-aboard

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-10-05 11:16 UTC

This package is auto-updated.

Last update: 2026-10-05 12:55:55 UTC


README

Laravel All Aboard

PHP 8.2+  Laravel 12 or 13  Lint  Tests  Downloads on Packagist  MIT licence  All Aboard GraphQL API 
 

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.