Клиент API РЖД: поиск поездов, свободные места в вагонах, схемы вагонов, маршруты следования, календарь цен и справочники.
- Поиск поездов — расписание, время в пути, расстояние, типы вагонов, цены и количество мест
- Поиск с пересадками — цепочки рейсов там, где прямых поездов нет, с ожиданием и переездами между вокзалами
- Вагоны и места — номера свободных мест по вагонам и купе, цены, услуги
- Схемы вагонов — чертеж вагона в SVG и фотографии салона
- Маршрут поезда — все остановки с местным и московским временем, стоянками и часовыми поясами
- Станции — поиск кодов по названию, популярные города, коды смежных видов транспорта
- Календарь цен — минимальные цены по датам, даты с местами и горизонт продажи
- Справочники — тарифы, карты и абонементы, конфигурация сайта
- Аэроэкспресс — тарифы на поездку в аэропорт
- Установка
- Быстрый старт
- Настройка
- Методы
- Ошибки
- Данные вне моделей
- Примеры
- Тесты
- Переход с 5.x
- Описание эндпоинтов
composer require visavi/rzd-apiБиблиотека не привязана к конкретному HTTP-клиенту: ей нужна любая реализация PSR-18 и PSR-17. Если в проекте их ещё нет, достаточно Guzzle — он даёт и клиент, и фабрики:
composer require guzzlehttp/guzzleПодойдёт любая другая пара, проверенные варианты:
| Клиент PSR-18 | Фабрики PSR-17 |
|---|---|
guzzlehttp/guzzle |
приедут вместе с ним (guzzlehttp/psr7), ставить отдельно не нужно |
symfony/http-client |
нужны отдельно: nyholm/psr7, laminas/laminas-diactoros, httpsoft/http-message |
php-http/curl-client |
нужны отдельно, те же варианты |
Реализация подхватывается автоматически через php-http/discovery, либо
передаётся в конструктор явно. Обратите внимание: symfony/http-client без
реализации PSR-17 не заработает — фабрик в нём нет.
Требования: PHP 8.2 или новее и расширение json.
Версия 6.0 работает с новым API ticket.rzd.ru и несовместима с 5.x. Если код
написан под прежний протокол pass.rzd.ru и переписывать его сейчас не нужно,
оставайтесь на пятой версии:
composer require visavi/rzd-api:^5.0Она работоспособна, но новых данных туда не добавляется. Порядок перехода описан в docs/migration.md.
use Rzd\Client;
use Rzd\Request\CarSearch;
use Rzd\Request\TrainSearch;
$client = new Client();
$result = $client->trains->search(new TrainSearch(
origin: '2000000', // Москва
destination: '2004000', // Санкт-Петербург
date: new DateTimeImmutable('+7 days'),
));
foreach ($result as $train) {
printf(
"%s %s → %s, мест %d, от %.2f\n",
$train->number,
$train->departure->format('d.m H:i'),
$train->arrival->format('d.m H:i'),
$train->freeSeats(),
$train->minPrice(),
);
}
// Вагоны первого поезда: параметры собираются из него самого
$cars = $client->cars->search(CarSearch::forTrain($result->trains[0]));
foreach ($cars->withSeats() as $car) {
printf("вагон %s %s, места: %s\n", $car->number, $car->typeName, $car->freePlaces);
}Коды станций ищутся по названию:
foreach ($client->stations->suggest('Чебоксары') as $station) {
printf("%s %s %s\n", $station->name, $station->code, $station->timezone);
}Сетевые настройки — таймаут, прокси, повторы, логирование — задаются в HTTP-клиенте, а не в библиотеке. Так они настраиваются один раз для всего приложения, и библиотека не дублирует возможности клиента.
Сайт принимает запросы только с российских адресов, с остальных соединение уходит в таймаут. Вне РФ нужен прокси:
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Psr7\HttpFactory;
use Rzd\Client;
use Rzd\Config;
$factory = new HttpFactory();
$client = new Client(
config: new Config(),
httpClient: new GuzzleClient([
'proxy' => 'socks5://127.0.0.1:1080',
'timeout' => 30,
]),
requestFactory: $factory,
streamFactory: $factory,
);Если клиент и фабрики не переданы, они определяются автоматически среди установленных реализаций PSR-18 и PSR-17.
Настройки самой библиотеки:
use Rzd\Config;
use Rzd\Enum\Language;
$config = new Config(
// Язык ответов сайта
language: Language::English,
// Без User-Agent сайт отвечает 403, поэтому по умолчанию подставляется браузерный
userAgent: 'MyApp/1.0',
// Дополнительные заголовки к каждому запросу
headers: ['X-Client-ID' => '22900'],
);Настройки неизменяемы, копию с другими значениями дают методы withLanguage,
withUserAgent и withHeaders.
Заголовки из настроек уходят со всеми запросами. Исключение одно: поиск с
пересадками добавляет к своему запросу куку LANG_SITE — без неё сайт отвечает
500, поэтому она важнее пользовательского Cookie.
Клиент разбит по ресурсам: trains, cars, routes, stations, prices,
references, aeroexpress, transfers.
Параметры передаются объектами запросов. Собрать такой объект можно двумя
способами: обычным конструктором с именованными аргументами либо фабрикой из
уже полученного ответа — CarSearch::forTrain($train),
RouteSearch::forTrain($train), CarSchemeSearch::forCar($car, $train),
TrainSearch::forStations($from, $to, $date),
TransferSearch::forStations($from, $to, $date),
TrainSearch::forDirection($direction, $date),
TransferSearch::forDirection($direction, $date).
Фабрики нужны не только для краткости. Например, запросу вагонов нужны коды
конкретных вокзалов (2000003 — Москва Казанская), а не города
(2000000 — Москва), которым искали поезда, плюс система бронирования поезда.
Перенося это руками, легко получить пустой ответ вместо ошибки.
$result = $client->trains->search(new TrainSearch(
origin: '2000000',
destination: '2004000',
date: new DateTimeImmutable('2026-08-01'),
adults: 2, // взрослых пассажиров
children: 1, // детей без места
fromSchedule: true, // добавлять поезда из расписания, у которых мест ещё нет
largeFamily: false, // искать места для многодетных
groupCars: false, // группировать вагоны одного типа
));Если станции найдены подсказкой, коды доставать не нужно:
$result = $client->trains->search(TrainSearch::forStations(
$client->stations->find('Москва'),
$client->stations->find('Санкт-Петербург'),
new DateTimeImmutable('2026-08-01'),
adults: 2,
));Фабрика принимает и ненайденную станцию: если find вернул null, будет
InvalidArgumentException вместо запроса с пустым кодом.
Популярное направление содержит обе станции сразу, подсказки ему не нужны:
$direction = $client->stations->directions()[0];
$result = $client->trains->search(TrainSearch::forDirection($direction, $date));SearchResult перебирается как список поездов и хранит данные направления,
поэтому пустой результат отличим от отсутствия мест:
count($result); // сколько поездов найдено
$result->trains; // список Train
$result->withSeats(); // только поезда со свободными местами
$result->cheapest(); // самый дешёвый из тех, где есть места
$result->fastest(); // самый быстрый из тех, где есть места
$result->originName; // название станции отправления
$result->destinationName;
$result->moscowTime; // текущее московское время сайта
$result->partial; // сайт вернул не все поезда направленияПоезд:
$train->number; // 130Х
$train->displayNumber; // номер для показа пользователю
$train->name; // название фирменного поезда, иначе null
$train->description; // категория: СК, ПАСС, СКОР
$train->departure; // DateTimeImmutable по местному времени станции
$train->arrival;
$train->moscowDeparture; // то же по московскому времени
$train->duration; // время в пути в минутах
$train->distance; // расстояние в километрах
$train->carriers; // ['ФПК']
$train->carGroups; // группы вагонов с ценами и местами
$train->freeSeats(); // свободных мест по всем группам
$train->minPrice(); // минимальная цена по всем группам
$train->provider; // система бронирования, нужна для запроса вагонов
$train->originStationCode; // код станции, а не города — тоже нужен для вагоновПоиск туда-обратно делает два запроса: сайт не умеет искать пару маршрутов одним, его собственная страница туда-обратно поступает так же. Параметры обратного плеча повторяют первое, меняются только станции и дата:
$trip = $client->trains->searchReturn($search, new DateTimeImmutable('2026-08-05'));
$trip->forward; // SearchResult туда
$trip->back; // SearchResult обратно
$trip->hasSeats(); // есть места в обе стороны
$trip->minPrice(); // минимальная стоимость поездки целикомГруппа вагонов:
$group->type; // Compartment, Luxury, Soft, ReservedSeat, Sedentary
$group->typeName; // КУПЕ, СВ, ЛЮКС, ПЛАЦ, СИДЯЧИЙ
$group->serviceClasses; // ['2Э']
$group->places; // свободных мест
$group->lowerPlaces; // из них нижних
$group->upperPlaces;
$group->minPrice;
$group->maxPrice;
$group->availability; // Available, LastPlaces, NotAvailableОбычный поиск отдаёт только прямые поезда. Между городами без прямого
сообщения цепочку из нескольких рейсов строит отдельный метод. Города здесь
задаются идентификаторами узлов сайта, а не кодами станций: их отдаёт
подсказка станций в поле nodeId.
use Rzd\Enum\TransportProvider;
use Rzd\Request\TransferSearch;
$result = $client->transfers->search(new TransferSearch(
origin: '5a13bdc3340c745ca1e8aa54', // Новый Уренгой
destination: '5a13baab340c745ca1e7f31c', // Абакан
date: new DateTimeImmutable('2026-08-20'),
minTrips: 2, // наименьшее число рейсов в цепочке, 1 добавит прямые
maxTrips: 4, // наибольшее, то есть пересадок плюс один
maxResults: 200, // предел числа вариантов
providers: [TransportProvider::Rails], // виды транспорта в поиске
));Готовый запрос можно собрать прямо из подсказок:
$request = TransferSearch::forStations(
$client->stations->find('Новый Уренгой'),
$client->stations->find('Абакан'),
new DateTimeImmutable('2026-08-20'),
);Подсказка возвращает и город, и его вокзалы. Годится любой узел, но выдача
отличается: город объединяет вокзалы, а отдельный вокзал даёт больше вариантов
от себя самого — Москва → Архангельск это 8 вариантов от города и 12 от
Ярославского вокзала. Узел города станции доступен как $station->cityId.
Результат перебирается как список вариантов поездки:
count($result); // сколько вариантов найдено
$result->routes; // список TransferRoute
$result->withSeats(); // варианты, где места есть на всех плечах
$result->cheapest(); // самый дешёвый
$result->fastest(); // самый быстрыйВариант поездки перебирается как список плеч — частей, оформляемых одним билетом:
$route->changes(); // число пересадок
$route->minPrice; // стоимость всей поездки по самым дешёвым местам
$route->maxPrice;
$route->duration(); // время в пути в минутах, вместе с ожиданием
$route->waits(); // ожидание на каждой пересадке, в минутах
$route->waitTotal(); // сколько всего стоять на пересадках
$route->departure(); // отправление первого рейса
$route->arrival(); // прибытие последнего
$route->origin(); // Place начала поездки
$route->destination();
$route->hasSeats(); // места есть на всех плечах
$route->trips(); // все рейсы поездки подряд, list<Trip>
$route->legs; // плечи, list<RouteLeg>
$route->transfers; // переезды между вокзалами, list<Transfer>Рейс:
$trip->number; // номер поезда, например 002Э
$trip->transportType; // Train, Bus, Airplane
$trip->origin; // Place, с названием станции и города
$trip->destination;
$trip->departure;
$trip->arrival;
$trip->duration(); // время в пути в минутах
$trip->distance; // километров
$trip->freePlaces;
$trip->minPrice;
$trip->maxPrice;
$trip->products; // классы обслуживания с ценами, list<TripProduct>
$trip->train(); // Train со всеми данными обычного поиска, либо nullСайт вкладывает в рейс поезда полный ответ обычного поиска, поэтому вагоны и цены доступны без второго запроса:
foreach ($route->trips() as $trip) {
foreach ($trip->train()?->carGroups ?? [] as $group) {
printf("%s %d мест от %s\n", $group->typeName, $group->places, $group->minPrice);
}
}Переезд между вокзалами появляется, когда цепочка приходит на один вокзал
города, а уезжает с другого. Пустой список transfers означает, что все
пересадки происходят на одном вокзале, а не что пересадок нет:
$transfer->origin?->name; // Ярославль (Московский вокзал)
$transfer->destination?->name; // Ярославль-Главный
$transfer->duration; // время переезда в минутах, как у рейса и поездки
$transfer->seconds; // то же без округления
$transfer->price; // стоимостьЗапросу вагонов нужны коды конкретных станций поезда и его система бронирования. Всё это есть в найденном поезде, поэтому проще собрать параметры из него:
$cars = $client->cars->search(CarSearch::forTrain($train));Или задать вручную:
$cars = $client->cars->search(new CarSearch(
origin: '2000003',
destination: '2060500',
trainNumber: '130Х',
departure: new DateTimeImmutable('2026-08-01 00:20'),
provider: 'P1',
));count($cars); // сколько вагонов
$cars->withSeats(); // только вагоны со свободными местами
$cars->cheapest(); // самый дешёвый из тех, где есть места
$cars->train; // данные поезда, приходят тем же ответом
$car->number; // 09
$car->typeName; // КУПЕ
$car->serviceClass; // 2Э
$car->freePlaces; // «2, 4» как отдаёт сайт
$car->placeNumbers(); // [2, 4] числами, пометки пола отброшены
$car->places; // свободных мест
$car->minPrice;
$car->maxPrice;
$car->serviceCost; // стоимость сервисных услуг, входит в цену
$car->schemeId; // идентификатор схемы вагона
$car->subType; // 64К, определяет схему
foreach ($car->compartments as $compartment) {
printf("купе %s: %s\n", $compartment->number, implode(', ', $compartment->placeNumbers()));
}Пометка после номера места (4М, 12Ж, 22С) означает пол пассажиров в купе,
к номеру места отношения не имеет и в placeNumbers() отбрасывается.
use Rzd\Enum\SchemeView;
use Rzd\Request\CarSchemeSearch;
$request = CarSchemeSearch::forCar($car, $train);
$scheme = $client->cars->scheme($request);
$scheme->schemeId; // 567
$scheme->isTwoStorey();
$scheme->has(SchemeView::DesktopSecondStorey);
// Чертёж вагона в SVG
$svg = $client->cars->schemeImage($scheme->schemeId, SchemeView::DesktopFirstStorey);
// Фотографии салона
foreach ($client->cars->images($request) as $image) {
printf("%s %s\n", $image->title, $image->content);
}use Rzd\Request\RouteSearch;
$route = $client->routes->search(RouteSearch::forTrain($train));
foreach ($route as $stop) {
printf(
"%s приб %s отпр %s стоянка %s мин, МСК %+d\n",
$stop->stationName,
$stop->arrival?->format('d.m H:i') ?? '',
$stop->departure?->format('d.m H:i') ?? '',
$stop->stopDuration,
$stop->timeZoneDifference,
);
}У части поездов сайт отдаёт несколько вариантов маршрута, например с
прицепными вагонами. search возвращает основной, all — все.
Прежнее имя routes->forTrain() сохранено до 7.0, но помечено устаревшим:
рядом с фабрикой запроса вызов читался как
routes->forTrain(RouteSearch::forTrain($train)).
// Поиск по части названия, повторяющиеся коды отбрасываются
$stations = $client->stations->suggest('ЧЕБ');
// Первая подходящая станция или null: подсказки отсортированы по близости
// к запросу, поэтому для готового названия города разбирать список незачем
$station = $client->stations->find('Чебоксары');
$station->name; // Чебоксары
$station->code; // 2060620, он нужен для поиска поездов
$station->nodeId; // идентификатор узла нового сайта, нужен для пересадок
$station->cityId; // узел города станции, у самого города равен nodeId
$station->isCity(); // узел города, а не отдельного вокзала
$station->region; // Российская Федерация
$station->type; // Город, Станция, Поселок
$station->timezone; // Europe/Moscow
$station->codes; // ['Railway' => ..., 'Cbdpr' => ..., 'Bus' => ..., 'Avia' => ...]
$station->stationCodes; // коды всех вокзалов города
// Популярные города
$client->stations->popular();
// Популярные направления: готовые пары станций
foreach ($client->stations->directions() as $direction) {
printf("%s → %s\n", $direction->origin?->name, $direction->destination?->name);
}
// Город или станция по идентификатору узла
$client->stations->byNodeId('5a323c29340c7441a0a556bb');// Даты, на которые между станциями есть поезда с местами
$dates = $client->prices->availability(
'2000000',
'2004000',
new DateTimeImmutable('+1 day'),
new DateTimeImmutable('+21 days'),
);
// Минимальные цены по датам отправления
foreach ($client->prices->calendar('2000000', '2004000', new DateTimeImmutable('+1 day')) as $day) {
printf("%s от %.2f\n", $day->date->format('d.m.Y'), $day->minPrice);
$day->byCarType(); // ['Compartment' => 2037.30, 'Luxury' => 9043.30]
$day->carriers(); // ['ФПК', 'ДОСС']
}Отдельный вопрос — до какой даты продажа открыта вообще. Сайт отдаёт календарь примерно на тринадцать месяцев вперёд, из которых заполнены только доступные:
foreach ($client->prices->saleCalendar('2000000', '2004000') as $month) {
printf("%d-%02d: дней в продаже %d\n", $month->year, $month->month, count($month->saleDays));
$month->availableDays; // числа месяца, на которые есть поезда
$month->saleDays; // числа месяца, на которые открыта продажа
$month->isOnSale(15); // открыта ли продажа на 15-е
$month->dates(); // те же дни объектами DateTimeImmutable
}foreach ($client->references->tariffs() as $tariff) {
printf("%s %s %s\n", $tariff->sysName, $tariff->category, $tariff->isActive() ? '' : 'недействующий');
}
// Конфигурация сайта отдаётся массивом: набор ключей меняется сайтом
$config = $client->references->appConfig();Карты и абонементы перевозчиков — скидка в процентах либо фиксированное число поездок:
foreach ($client->references->cards() as $card) {
printf("%s %s %.2f\n", $card->code, $card->name, $card->price);
$card->discount; // скидка в процентах
$card->tripQuantity; // число поездок у абонемента
$card->activeDays; // срок действия в днях
$card->carTypes; // ['Compartment', 'Sedentary']
$card->serviceClasses;
$card->isPass(); // абонемент на поездки, а не скидочная карта
$card->fitsCarType('Compartment');
}У аэроэкспресса свои тарифы: место обычно не фиксировано, а билет действует несколько месяцев, поэтому поиска поездов здесь нет.
foreach ($client->aeroexpress->tariffs(new DateTimeImmutable('+7 days')) as $tariff) {
printf("%s %.2f\n", $tariff->name, $tariff->price);
$tariff->type; // Standard, Business
$tariff->description; // условия применения
$tariff->maxTickets; // сколько билетов можно купить одним заказом
$tariff->guaranteedSeat;
$tariff->documentTypes;
}Коды станций необязательны: без них приходят тарифы, действующие на любом направлении от аэропортов и к ним.
Все исключения библиотеки реализуют Rzd\Exception\RzdException, поэтому
ловятся одним catch:
use Rzd\Exception\ApiException;
use Rzd\Exception\ForbiddenException;
use Rzd\Exception\InvalidArgumentException;
use Rzd\Exception\MalformedResponseException;
use Rzd\Exception\RzdException;
use Rzd\Exception\TransportException;
try {
$client->trains->search($search);
} catch (TransportException $e) {
// Сайт недоступен: таймаут, обрыв соединения, ошибка прокси.
// Вне РФ самая частая ошибка
} catch (ForbiddenException $e) {
// Запрос отбит защитой сайта, обычно из-за пустого User-Agent
} catch (ApiException $e) {
// Сайт ответил ошибкой
$e->statusCode(); // 500
$e->errorCode(); // INTERNAL_ERROR
$e->body(); // тело ответа целиком
} catch (MalformedResponseException $e) {
// Ответ успешный, но это не JSON
} catch (InvalidArgumentException $e) {
// Некорректные параметры, обнаружены до обращения к сайту
} catch (RzdException $e) {
// Любая ошибка библиотеки
}Сайт отдаёт у поезда больше семидесяти полей, у вагона больше восьмидесяти. Модели описывают то, что нужно на практике, а полный ответ остаётся доступен, поэтому редкое поле не требует правки библиотеки:
$train->get('TrainBrandCode'); // 3033
$train->get('BoardingSystemTypes');
$train->raw; // весь ответ сайта по этому поезду
$result->raw; // весь ответ целикомЗначения, которые присылает сайт (CarType, Provider, CarNumeration),
остаются строками, а не перечислениями: сайт может добавить новое значение,
и перечисление сломало бы клиент на ровном месте. Перечисления используются
только там, где значение выбираем мы: Language, SchemeView.
Запускаются из корня проекта, вне РФ — с прокси:
php examples/index.php # список примеров
php examples/index.php search_trains # запустить один
php examples/search_trains.php # то же напрямую
RZD_PROXY=socks5://127.0.0.1:1080 php examples/index.php search_trainsЛибо в браузере, со страницей-навигацией по примерам:
RZD_PROXY=socks5://127.0.0.1:1080 php -S localhost:8000 -t examples| Пример | Что показывает |
|---|---|
| search_trains.php | поиск поездов, цены, типы вагонов |
| round_trip.php | поиск туда-обратно, стоимость поездки целиком |
| transfers.php | цепочки рейсов с пересадками, ожидание |
| car_places.php | вагоны, свободные места по купе |
| car_scheme.php | схема вагона в SVG и фотографии салона |
| train_route.php | маршрут поезда по станциям |
| stations.php | коды станций, популярные города |
| price_calendar.php | горизонт продажи, наличие мест, цены по датам |
| cards.php | карты и абонементы со скидками |
| aeroexpress.php | тарифы аэроэкспресса |
| tariffs.php | справочник тарифов, конфигурация сайта |
composer test # на моках, без обращения к сети
composer test:coverage # с покрытием
composer analyse # PHPStan, уровень 8Живые запросы к сайту вынесены в группу live и исключены из обычного прогона
и из CI, поскольку сайт принимает их только с российских адресов:
RZD_PROXY=socks5://127.0.0.1:1080 composer test:liveMIT