Skip to content

GTFS Core

Imports GTFS feeds into Drupal and gives every file in the specification a real entity, with the relationships between them intact. A route knows its agency and its trips, a trip knows its stops and its shape, a stop knows the routes that serve it.

It is built to be the foundation other things sit on: a website per route, a departure board, a timetable, an API for someone else's app. The importer keeps those entities in sync with the feed, so fields you add yourself survive re-imports rather than being wiped each time the agency publishes.

How the data is stored

Everything is one entity type, gtfs_row, with a bundle per GTFS file — routes, trips, stops, stop_times and the rest. Bundles are declared in code with the #[GTFSFile] attribute rather than through the UI, so a module can add a file the specification does not define. GTFS+ does exactly that.

Each import belongs to a feed, and rows are scoped to their feed. A site can import the same agency many times — different versions, different sources — and they stay separate.

Requirements

A private file path is required. Feeds are downloaded and extracted under Drupal's private file system. Set $settings['file_private_path'] in settings.php to a directory outside the web root before importing. The status report warns if it is missing.

Installation

composer require drupal/gtfs

Then enable it as you would any module. See Installing modules if you need the longer version.

Getting started

  1. Add a feed. Go to Administration › GTFS › Feeds (/admin/gtfs/feed) and add one, pointing at the URL of a GTFS zip archive.
  2. Import it. Use the feed's Import tab and follow the prompts. Large feeds run as a batch across several requests.
  3. Browse what came in. Administration › GTFS (/admin/gtfs) lists every file with its rows.

Re-run the import whenever the agency publishes an update. Rows are matched on their GTFS identifiers and updated in place, so anything you added on top of them is preserved.

Exporting a feed

A feed can be written back out as a GTFS zip, as the site holds it now, rows edited in Drupal included. Give a role the Export GTFS feeds permission, then use the feed's Export tab (/admin/gtfs/feed/{id}/export). The zip is built as a batch and kept in private files for download until the next export replaces it.

From the command line:

drush gtfs:feed:export my-feed-alias --output=/tmp/feed.zip

Add --validate to run the result through MobilityData's GTFS validator. It needs Java 17 or later and the validator's CLI jar, given with --validator-jar or the GTFS_VALIDATOR_JAR environment variable. The command fails if the validator reports any errors, and leaves report.html beside the zip.

The export carries what the importer kept. Columns and files the module does not define, such as a licence text in the archive, were never stored, so they are not written back. Columns come out in the order the file is declared rather than the source's order. Optional columns that no row fills in are left out.

REST API

Every resource lives under /gtfs/api/v2/feeds/{feed_alias}/, so a request is always scoped to one feed. A site that has imported the same agency several times keeps those imports separate: nothing in a response crosses from one feed to another.

Responses carry the GTFS fields flat, alongside a label and a links map of related resources. Follow links rather than building paths by hand — that is the supported way to move between resources.

List responses are paginated. Pass ?limit= and ?offset=; read meta.totalCount for the size of the whole set, meta.dataCount for the size of the page, and meta.links.next and meta.links.previous to walk it.

/gtfs/api/v2/feeds and /gtfs/api/v2/feeds/{feed_alias} both report what a feed currently is: its id, last_imported, and its own feed_info.txt row. A client caching responses needs all of them, because an alias is deliberately stable — promotion changes the id, re-importing the same feed changes last_imported, and neither moves the alias. The list carries the same fields as the single-feed form, so comparing feeds costs one request rather than one per feed.

Field values are JSON strings, including numeric ones — stop_lat is "37.7212", not 37.7212, and route_type is "3", not 3. This is consistent across every field and every resource, so parse on the client where you need numbers. It follows GTFS itself, which is a CSV format with no types.

Resources are exposed through Drupal's REST module; enable and grant them as you would any other REST resource.

Submodules

GTFS Fares (gtfs_fares) — entity types for the fare files: fare attributes, fare rules, fare products, and the rest of the fares model. Enable it if your feed has them.

Extending

Bundles are declared with the #[GTFSFile] attribute on a class extending Drupal\gtfs\Entity\GTFSRow. Adding a file the specification does not define, or changing the fields on one that it does, means declaring such a class — see the gtfs_fares submodule for a worked example, and GTFS+ for a separate project that does it.

gtfs.api.php documents the hooks. Currently:

  • hook_gtfs_yasumi_provider_alter() — choose the Yasumi holiday provider that names the dates in calendar_dates.txt, since agencies commonly run a reduced timetable on locally observed holidays. The service and schedules resources put the name beside the date, so a picker can offer "Labor Day" rather than the 7th. The default is the United States, so any agency outside it should implement this hook, passing either a short name understood by Yasumi::create() such as 'USA', or a fully-qualified class name. An agency with its own service days — a school break, a seasonal timetable — can subclass a provider and name that. A date no provider knows keeps its holiday as null, which is the ordinary case.

Development

The module ships PHPUnit tests, a phpunit.xml.dist, and a GitLab CI configuration.

./vendor/bin/phpunit -c phpunit.xml.dist

Run that from this directory. It needs the module's own dev dependencies, which means composer install here — and because a contrib module does not declare where Drupal packages come from, that install only resolves on a checkout where the drupal.org Composer repository is configured. Drupal.org CI supplies it; a local checkout needs it adding.

The configuration bootstraps core rather than tests/bootstrap.php, because core's bootstrap is what registers the Drupal\Tests\{module} namespaces that kernel tests extending EntityKernelTestBase rely on. Its relative path assumes this module sits in a site's modules directory.

See ROADMAP.md for planned work.

Issues

File issues and merge requests at https://www.drupal.org/project/issues/gtfs.

Maintainers

  • Tyler Brown Cifu Shuster - tbcs
  • Jason P. Salter - jpsalter

This project has been sponsored by: