About this project

Straight from the repository's README, so this page says exactly what the project says about itself.

The Air Trips Builder is an application designed to help users search for one-way and round-trip flights easily. The application comes with built-in databases for airports, airlines, and countries, providing a comprehensive flight booking experience. Users can also order flights and manage their bookings through a personal page.

About The Project

PHP Coding Assessment for the Backend PHP Developer role at FlightHub.

Built With

PHP version MySQL version Bootstrap JQuery FontAwesome

Features

Flight Search

Effortlessly search for both one-way and round-trip flights with ease.

Robust REST API

Leverage the capabilities of built-in REST API, offered flexibility and convenience in accessing data.

Autofill Search Form

Simplify search experience with autofill functionality, which populates search forms with relevant airport data.

Flexible Flight Sorting

Sorting search results according to preferences, ensuring to find the ideal flights for any journey.

Tailored Departure Time

Customized flights search by specifying preferred departure time, ensuring a travel schedule that suits.

Airline Filtering

Efficiently narrowed down search results by filtering airlines, allowing to focus on preferred carriers.

Paginated Search Results

Navigate search results effortlessly with paginated display, enhancing readability and user experience.

Seamless Flight Ordering

Streamline flight booking process with intuitive flight ordering functionality.

Personalized User Pages

Personalized user pages that shows all information about bookings and empower to efficiently manage ordered flights.

Comprehensive Database

A comprehensive database containing information about airports, airlines, and countries.

Installation

1. Clone the Repository

First, clone the repository using the following command:

git clone https://github.com/ivan-tarasov/fh-trip-builder.git

2. Install Dependencies

Navigate to the project directory and install the required dependencies using Composer:

cd fh-trip-builder
composer install

3. Configure Environment

Copy the sample environment file to create a new .env file:

cp .env.sample .env

Edit the .env file and provide your MySQL database credentials:

DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=database_name
DB_USERNAME=database_user
DB_PASSWORD=database_password

4. Point the Web Server at public/

The document root is public/, not the project. It holds index.php and the four asset directories and nothing else. Everything the app needs — .env, config/, src/, vendor/, templates/, the noah CLI — sits one level above it, where a request cannot name it at all.

In cPanel: Domains → the domain → Document Root → append /public. In a plain vhost: DocumentRoot /path/to/fh-trip-builder/public, and the matching <Directory> block.

Then copy both web server configs into place:

cp .htaccess.example .htaccess
cp public/.htaccess.example public/.htaccess

Neither live .htaccess is tracked by git, because hosting panels such as cPanel own those files and rewrite them — notably the generated block that pins the PHP version. Keeping them untracked stops a deploy from overwriting those changes. If your host has already created one, leave it alone and just make sure it contains the blocks from the matching .example.

The two files do different jobs. public/.htaccess routes every URL to the front controller and refuses dotfiles. The one at the project root refuses everything, and on a correct deployment is never read — Apache reads .htaccess from the document root downward and never above it. It is there for the deployment where the document root was left pointing at the project: the site stops, which is the outcome to want. This project served .env, composer.lock and executed .php under src/ and tests/ for as long as nobody happened to type the URL, and that condition announces itself to nobody.

After deploying, check from outside — every line should read 404, because none of these is in the document root:

for p in .env noah composer.lock src/Cdn.php config/common/site.php templates/partials/header.html.twig; do
  printf '%-40s %s\n' "/$p" "$(curl -sS -o /dev/null -w '%{http_code}' "https://YOUR-HOST/$p")"
done

/, /airside, /css/main.css and /js/global.js should answer 200, and /.well-known/ must not be refused — that is where AutoSSL and Let's Encrypt write the challenge files that renew your certificate.

If your host will not let you move the document root, replace the refusal in the project root's .htaccess with a rewrite into public/:

RewriteEngine On
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ public/$1 [L]

Every request then lands inside public/, so /composer.lock becomes /public/composer.lock, which does not exist. It reaches the same place by a weaker route: the project root stays servable, and correctness depends on that rule being right rather than on the files being somewhere else.

5. Run Installation Command

Execute the following command to run the installation process:

php noah install

6. Import Help Articles

The help articles and the categories grouping them live in config/content/help as markdown, and are loaded into the database by their own command:

php noah articles:import

Or fold this, the Airside posts, and each aircraft type's hero image into the install:

php noah app:install --with-content

This used to be a separate step on purpose: articles were seeded from a CSV, and an install refreshed every column, so an edited article would be reverted. That is no longer how it works. The files are the whole truth now — the importer deletes rows no file describes, precisely so that a database which has been imported before and a fresh one cannot quietly disagree — and re-running it cannot revert an edit, because edits are made in the files.

It is still a flag rather than the default. Running app:install to add a missing column should not also re-import the content.

7. Generate Flights

To generate flight data, use the following command:

php noah flights:add 3650000

Say a number, and say this one. Without an argument the command asks and offers 10,000. That is enough for a site you can click around and nowhere near enough to search: the network has 49,000 routes across ninety days, and a search that finds nothing is the one thing this application exists to do.

3,650,000 is a year at 10,000 a day, which is what the nightly run maintains. A year because the calendar offers a year: the picker stops on the same day the flights do, so there is no date a visitor can choose that answers nothing (E30, #215). The number was measured on mid-ranked routes — rank 3,000 of 49,000, which is what an ordinary visitor searches — by asking how often a search comes back empty:

flights/day routes that answer search total rows disk
4,500 16 of 25 170 ms 405,000 247 MB
8,000 22 of 25 221 ms 718,000 437 MB
10,000 24 of 25 268 ms 896,000 546 MB

It takes about four minutes and leaves the table around 2.2 GB.

Once there is a network it is kept up rather than regenerated. flights:add 10000 --level runs nightly and puts a day's worth into whichever days are thinnest — normally the one that has just entered the ninety-day window, and after a missed night, both of them. --day=90 or --day=2026-12-12 fills one named day by hand.

It no longer needs a raised memory limit. The generator used to hold every flight until the end — about 1 MB per thousand — and flights:add 200000 was a fatal at PHP's default 128 MB somewhere past ninety thousand, having written nothing and reported nothing (E19, #178). It writes as it goes now and stays around 25 MB whatever the count.

8. Access the Project

You're all set! Open your preferred web browser and navigate to the project URL to start using the application.

Local Development with Docker

For local development only: PHP 8.4 with Apache, and MariaDB 10.6, which is what production runs and what a pull request is tested on. You need Docker Desktop or OrbStack. The repository is mounted into the container, so an edit shows on the next reload.

First run

cp .env.sample .env
cp public/.htaccess.example public/.htaccess
docker compose up -d
docker compose exec app composer install
docker compose exec app php noah app:install
docker compose exec app php noah db:migrate
docker compose exec app php noah articles:import
docker compose exec app php noah flights:add 100000

The site is then at localhost:8888. Nothing above runs on its own: starting the containers never touches the database.

100,000 flights is enough to see every page working and a quick start, but spread over a year that is about 270 a day, so many searches find nothing. For the network production keeps, add the rest later; step 7 above explains the number:

docker compose exec app php noah flights:add 3550000

Inside Docker the app connects to mariadb:3306 as trip_builder. Those values come from compose.yaml and win over the DB_* lines in .env, so .env can keep pointing at a database outside Docker.

Compose also reads .env to fill in variables, and treats $ as the start of one. Put a value that contains $, such as ADMIN_PASSWORD_HASH, in single quotes, or every docker compose command warns about it.

Everyday commands

docker compose up -d                       # start
docker compose stop                        # stop, keep containers
docker compose down                        # stop and remove containers (data stays)
docker compose logs -f app                 # Apache and PHP errors
docker compose exec app composer test      # Composer
docker compose exec app php noah list      # Noah
docker compose exec mariadb mariadb -uroot -proot trip_builder

Cron does not run in Docker. Run a scheduled job by hand with docker compose exec app php noah schedule:run.

Connecting from DataGrip

Choose the MariaDB driver. Host 127.0.0.1, port 3307, database trip_builder, user trip_builder, password trip_builder. The root password is root. These are local development values only.

Ports

Apache is on host port 8888 and MariaDB on 3307. To change them, set DOCKER_HTTP_PORT or DOCKER_DB_PORT in .env or in your shell, then run docker compose up -d again.

Where the data lives

MariaDB keeps its data in the named volume mariadb-data. docker compose down and up -d keep it. Only removing the volume deletes it:

docker compose down -v    # deletes the local database for good

Starting from a dump

Any .sql or .sql.gz file in docker/mariadb/init/ is imported into trip_builder, but only when the volume is empty. That means the first docker compose up -d, or the first one after down -v. A php noah db:backup file from backups/ works. Git ignores that directory's contents, because a dump holds bookings and passengers. Skip app:install and flights:add after an import, and run db:migrate to catch up.

Data Retention

A booking is kept for 90 days after its flight departs. Then it is deleted, along with everyone travelling on it.

bookings and booking_passengers hold a contact email, a phone number, passenger names, dates of birth and genders. This site is priced in CAD and addressed to Canadians, so that is personal information under PIPEDA, which asks that it be kept only as long as it is needed for the purpose it was collected for.

Ninety days rather than a year, because nothing here needs a year. Every read of a booking is scoped by session_id and there is no admin panel: once a visitor's session has gone, no query in this application can reach the row again. The only purpose the data still serves after departure is a visitor coming back to a trip they took, and ninety days is generous for that.

The window is BookingRepository::KEEP_DAYS_AFTER_DEPARTURE, and a test fails if it changes without this paragraph changing with it.

Of the card, only the brand and the last four digits are ever stored. The number, expiry and CVV are read from the posted form, checked, and never written anywhere.

Applying it:

php noah db:prune            # lists what it would remove, and removes nothing
php noah db:prune --force    # removes it

It lists by default because this is the one command here whose purpose is destroying data that has no other copy. It also drops rate-limit counters for hours that have already finished, which is housekeeping rather than privacy, and content-source calls older than 30 days, which hold no personal data and are only read for a source's speed and errors.

Do not put it on its own cron line. The server runs one scheduled command and the schedule lives in this repository — see below.

Scheduled Work

The server has one cron line. What runs and when is the scheduled_jobs table, edited from /admin/schedule.

* * * * * cd /path/to/fh-trip-builder && php noah schedule:run >> ~/logs/schedule.log 2>&1

A job is a command from a fixed list (nothing that could wipe data or run itself), plus five crontab fields — minute, hour, day, month, weekday — the same five in the same order as cPanel's editor, so the two can be read against each other without counting positions. All five are required and none defaults to *: a schedule where forgetting the day field turns a monthly task into a daily one is a schedule that reads correctly while doing something else.

*, a number, a-b, a comma-separated list, and any of those with /step. Names (MON), the @daily aliases and the ? L W # extensions are not implemented, and a schedule using one is refused when it is saved rather than quietly read as something else.

A missed occurrence is caught up rather than skipped, which is the one way this differs from a real crontab: a tick lost to a deploy or a reboot is picked up on the next one instead of costing a day.

Before G19 (#377) the schedule was config/noah/schedule.php, a static file in git — before that the crontab was the only record of what this application runs unattended, so a rebuilt server or a reset hosting panel took the schedule with it and nothing said what had been lost. Editing it meant a pull request and a deploy. The database moved the schedule off the server without putting it back behind one: an edit from /admin/schedule takes effect on the very next tick.

A task is due when nothing has run since the moment it was last supposed to, so a tick missed by a deploy or a reboot catches up on the next one instead of skipping the day. schedule:run --pretend says what is due and runs none of it.

What you give up by having one line. With a line per task, a broken one is isolated. With one, if schedule:run stops firing then rates go stale and retention stops applying, together, and nothing says so.

Two things watch for that. schedule_runs records when each command last started and when it last worked — a command failing every night has a fresh first and a rotting second — and /health reports the age of the second. schedule_run_history keeps every attempt rather than only the latest one, and /admin/schedule links each job to its own. And because a broken crontab does not stop page requests, an ordinary request checks the same thing at most once an hour and writes a line to the log when something has stopped:

[19ba8457] Scheduled command `currency:rates` last worked 4d ago. Is cron still running `schedule:run`?

A task gets one whole period of grace first: a daily command that missed last night is a bad night, one that has missed two is something nobody is watching.

That makes a stopped scheduler visible when you look. Being told needs something outside polling /health, which this project does not have and should not be assumed to.

Health

curl -sS https://YOUR-HOST/health
{"status":"ok","db":"ok","schedule":"ok","tasks":{"currency:rates":"4h","db:prune --force":"4h"},"version":"v2.9.1-develop-1e47c8e"}

200 when a trivial query reaches the database, 503 when it does not. No authentication and nothing sensitive: the version is already in the footer of every page, and a check that needs a credential stops working the day the credential rotates.

status follows the database and not the schedule. Rates being two days old is not a reason to tell a load balancer the site is down, so a stopped scheduler shows in schedule instead — a monitor can assert on that field without confusing it with the site being unreachable. schedule reads unknown when the records could not be read at all, which is not the same as ok.

It exists because most of this application degrades rather than fails: a page will return 200 having swallowed a database error into an empty block, which is right for a visitor and useless for a monitor.

Content Sources

Search can get its flights from content sources -- FareSource first, then anyone else -- through one layer in src/ContentSource/, instead of the flights table. sources.shopping in /admin/settings/content-sources decides who:

  • table (the default): everybody searches the table, as before;
  • admin: a signed-in admin searches the sources and everybody else the table, so the two can be compared on one deploy;
  • everyone: everybody searches the sources.

Every step of a search goes through it, and checkout checks the chosen offer with its source and books it at the checked price; the booking itself is still made here, not at the source. Two fake sources ship on for admins, flying carrier ZZ through a hub called ZZH, so setting admin is all it takes to see the layer work. A search they answer is counted nowhere.

FareSource is the first real source, and ships off. To try it, set FARESOURCE_URL and FARESOURCE_AIR_TOKEN in .env, and its audience to admin; php noah sources:check says what is still missing, --live also calls it, and /health lists each source as off, ok or warn. Its tests run on answers recorded from FareSource (php noah fixtures:record faresource, on FARESOURCE_TEST_TOKEN), each checked against the contract pinned in tests/Fixtures/faresource/openapi/.

In the panel (E25.27), Settings → Diagnostics calls every source that is on, as sources:check --live does, and plays an unhappy path on the signed-in admin's own searches and price checks: a slow answer, the source down, a rate limit, a price that went up, a sold-out fare or a flight that is gone. The sources act it out on their test credentials (FareSource on FARESOURCE_TEST_TOKEN), a banner on every public page says it is playing, and signing out stops it. The dashboard shows the sources' live calls over the last day, and FareSource's balance, amber under sources.balance_warn CAD (500 by default) and red at 0.

The place pages' cheapest fares -- the fare blocks on city, country, airport and airline pages and the homepage's -- switch on their own setting, sources.reads.places, with the same three values. Through the sources they are FareSource's Prices product, read directly, so they need FARESOURCE_PRICES_TOKEN and no other token; an answer is kept until FareSource's generation moves, which the same token reads at /prices/v1/generation (E25.41).

The calendar's day prices and the fare alerts have their own setting too, sources.reads.calendar, read from the same product a month a call. FareSource prices from tomorrow, so on that path today's day has no fare. alerts:check runs with nobody signed in, so only everyone sends the alerts there -- and then an alert no longer waits for somebody to open its route's calendar.

Every call to a source is a row in source_calls, kept 30 days. Adding a source is src/ContentSource/ADDING_A_SOURCE.md.

php noah schedules:import keeps a copy of FareSource's schedule feed -- every flight number it flies -- in schedule_flights, nightly from the scheduler. It needs FARESOURCE_SCHEDULES_TOKEN, asks the feed's version first and pages only when that has moved, and keeps a copy only when the version held for the whole read; without a token it says so and does nothing. The airport boards, the airline and aircraft pages' flight counts and the route checks -- the footer's routes, the sitemap, the route lists on airport, city and home pages, and the search's route link -- read the copy instead of the flights table behind their own setting, sources.reads.schedules, with the same three values. So do the horizon -- a search ends at the copy's last day, not a year from today -- and the footer's flight count, which is the copy's dated flights up to that day. The dashboard shows the copy beside the flights table, and asks for a look when the last copy is more than two days old.

php noah reference:check lists the airport, city, airline and aircraft codes this app and FareSource do not share: the ones only this app has, which FareSource cannot answer for, and the ones only FareSource has. It changes nothing -- this app's lists stay the master. It needs FARESOURCE_REFERENCE_TOKEN, sends each list's ETag back so a list that has not moved costs no download, and without a token says so and compares nothing (E25.26).

Tests

composer test:unit

Needs nothing but PHP — no database, no .env, nothing installed beyond composer install. On a fresh clone it either passes or has found a real defect; there is no third answer, and a test is kept out of this suite if it needs rows. UnitSuiteNeedsNoDatabaseTest is what holds that line.

composer test

Both suites, which means it needs a database. Without one it fails on a single test that says so and points here, rather than skipping a fifth of the suite and reporting success. composer test:integration runs that half on its own.

Pointing the integration tests at a database

They take their settings from .env, the same ones php noah install uses, so if the install worked the tests will too. Where your MySQL is somewhere else — MAMP's port, a container, a socket you would rather not use — export the difference and it wins over .env:

DB_HOST=127.0.0.1 DB_PORT=8889 DB_SOCKET= composer test:integration

An empty value counts as an answer: DB_SOCKET= above is how you say "connect over TCP", rather than through the socket .env names.

A dozen of these tests skip themselves when the generated flight network happens to have nothing on the route they picked — no flights, or no connections to fold. Those skips are honest and depend on php noah flights:add having been run, so a run that reports a few of them has not gone wrong. CI holds the number: it fails when more tests skip than the MAX_SKIPPED in .github/workflows/ci.yml, so a new skip is one somebody meant to add.

The rest of the gates

composer lint
composer stan
composer cscheck

A syntax check across every file, static analysis, and the coding standard. composer csfix writes the standard's fixes rather than reporting them.

Releases

Merging a pull request into develop tags a release automatically (.github/workflows/release.yml). Versions are the calendar — vYEAR.MONTH.COUNT, where the count says which release of that month it is:

Tag Meaning
v2026.9.1 first release in September 2026
v2026.9.2 second, later the same month
v2026.10.1 first release in October
v2027.3.1 first release of 2027, cut in March

The year and month are read from the merge commit's own date in UTC, and the count from the tags already in that month. Nothing carries and nothing resets, which has two consequences worth knowing: no tag ever ends in .0, and the middle number is the calendar month rather than a sequence — so the first release of a year is v2027.3.1 if that is when it happens, not v2027.1.1.

Why not semver: nothing installs this app as a dependency, so "will this upgrade break me" has no asker. A date says the more useful thing, which is how fresh a build is. Tags up to v2.24.1 are the old scheme and are left as they were; v2026.9.1 is the first calendar one.

A pull request still needs exactly one label, enforced by pr-rules.yml, which fails a pull request carrying none or several. (Its PR rules check is required on develop, so that blocks merging.) The labels no longer decide the version — the calendar does — so they now say what kind of change it was, and no-release is the one that matters to the workflow:

  • release:major / release:minor / release:patch — classify the change, and make pull requests findable later.
  • no-release — merge without cutting a release at all.

Release notes are written by the workflow, not by GitHub: one line per commit since the previous tag, with UTC timestamps. The application footer shows the current tag, so a deployed server needs git fetch --tags for it to appear.

Noah

Noah is the command line interface (CLI) tool included with FareSky. It resides at the root of the application as the noah script and offers a variety of useful commands to assist you in building and managing application.

Its Version

Noah prints a version at the top of every command, and that is the only way to tell which Noah is on a server. Move it when Noah moves:

change bump
a new command minor
a fix to an existing one patch
a command removed or renamed major

A rename is a major because the crontab and the deployment runbook name commands — it breaks somebody's server rather than somebody's build.

It read v0.3.4 from August 2025 until September 2026, during which eleven commands were added and the CLI went from four to fifteen while calling itself the same thing. Nothing enforces this — if you are editing anything under src/Noah/ or the noah script, the version is part of the edit.

Getting Started

To get started with Noah, you need to navigate to the root directory of FareSky in your terminal.

Viewing Available Commands

To see a comprehensive list of all available Noah commands, you can use the following command:

php noah list

This will display a list of commands that you can utilize for various tasks.

Command Help Screens

For each command, there is a built-in "help" screen that provides information about the command's available arguments and options. To access this help screen, simply prepend the command with help. For example, if you want to learn more about the flights:add command, you can use:

php noah help flights:add

This will provide you with detailed information on how to use the flights:add command effectively.

Available Commands

Here are some of the available commands in Noah:

Installing Database Tables and Seeding Data

To set up the necessary database tables and populate them with initial data, you can use the install command:

php noah install

Database Management

  1. db:clear: Purge all data from database tables.
    php noah db:clear
    

Flights Management

  1. flights:add: Generate flights and add them to the database.

    php noah flights:add
    

    or

    php noah flights:add 10000
    

    Tris will generate and add 10,000 flights to database.

  2. flights:cleaning: cleaning flights

    php noah flights:cleaning
    

    This will delete flights older than today date from the database.

Articles Management

  1. articles:import: Make the help categories and articles in the database match the files in config/content/help.

    php noah articles:import
    

    One file per article, and one per category in the categories subdirectory. Both are a key: value header between --- fences followed by markdown, and both are refused by name — naming the file and the key — if a key is missing, repeated or misspelled, rather than imported with a gap.

    An article header carries title, category, icon, position and summary, and optionally a short label for the footer, where some titles are wider than the column. Its markdown is the article.

    A category header carries title, icon and position, and optionally an accent, which names a palette colour rather than being one: blue, green, orange, violet or pink. Anything else is not an error — it draws the default blue, because a colour is not worth failing an import over. It has no summary key: its markdown is the one sentence shown under the heading on the hub, so a category with nothing to say is refused the way an article with no prose is.

    position orders articles within their category, and categories against each other. An article naming a category that does not exist stops the import rather than being filed somewhere plausible.

    Re-running it is safe and quiet. Every article is written on every run, but updated_at only moves when the title, short label, summary or prose actually differs from the stored copy — so the date on an article is the date its content last changed, not the date somebody last ran the import.

    It also removes. A row whose file has gone is deleted, along with its translations and, for an article, the votes cast on it — those are keyed on the slug, so leaving them would hand a future article a tally about a page nobody can read. Without this, deleting a file left the article on every database that had already imported it while a fresh install never had it, so the two quietly stopped agreeing.

    Two things make that safe to have on by default. The command refuses the whole run when it finds no files at all, so a mistyped path or an unmounted volume cannot empty the tables. And it refuses when an article names a category no file describes, so deleting a category still in use fails before anything is written rather than orphaning its articles.

    To see what would be written and what would be removed, without doing either:

    php noah articles:import --dry-run
    
  2. airside:import: The same, for the Airside posts in config/content/airside.

    php noah airside:import
    

    Airside is the travel section, as against help, which is what a reader needs in order to finish a booking here. Same terms as the articles above: no seeder CSV, so app:install cannot revert a post, and the files are the whole truth — a post whose file is deleted is removed from the table on the next run.

    It refuses the whole run on a bad file rather than importing the rest: a missing hero_alt, an image no file backs, a date that does not exist, or a file name a URL could not hold. --dry-run reports without writing.

Aircraft Images

  1. aircraft:images: Copy each aircraft type's hand-generated hero image from a private backup prefix into the working one the app reads.

    php noah aircraft:images
    

    There is no generator here — every render was made by hand, once, and hand-uploaded to backup/aircrafts/ on the same bucket. This only brings an already-finished file into the app, matching it to a type by the code its filename ends in.

    Skips a type that already has an image, so it is safe to run again — a fresh install, or a server that already has some set from the admin panel, only ever gets what is still missing. --force re-copies every type regardless. --dry-run lists what would be copied without touching S3.

Seed Data

php noah app:install loads the CSVs in config/noah/db/seeders/; its README.md says where each came from. Airports and airlines are rebuilt from open data by hand, from files downloaded first (E25.42):

curl -LO https://davidmegginson.github.io/ourairports-data/airports.csv
curl -LO https://raw.githubusercontent.com/jpatokal/openflights/master/data/airports.dat
curl -LO https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat
php -d memory_limit=256M noah seeds:rebuild --ourairports=airports.csv \
  --openflights-airports=airports.dat --openflights-airlines=airlines.dat --dry-run

--dry-run says what would change; without it the two files are rewritten. A code is matched exactly, never followed to a new one, and one the open data does not have is dropped unless a flight uses it. Then app:install updates existing rows, and a migration removes the dropped ones.

Wikipedia Content

Three commands cache text (and, for cities, a photo) from Wikipedia's summary API, so a page never waits on Wikipedia or breaks when it is down.

  1. cities:content: a photo and a summary for every city. The photo is copied to our own S3 bucket rather than linked, so a page does not depend on Wikipedia's hotlinking rules.

    php noah cities:content
    
  2. countries:content: a summary for every country we sell flights to.

    php noah countries:content
    
  3. aircraft:content: a summary for every aircraft type.

    php noah aircraft:content
    

All three work the same way:

  • Each entry is looked up again once it is 90 days old. Anything newer is skipped, so running a command twice in a row does almost nothing. --force looks up everything again. --dry-run lists what would be looked up without asking Wikipedia.
  • "Wikipedia has nothing" is recorded too, so the next run does not ask about it again until it is due. A timeout or a server error is not recorded: the next run tries again.
  • A name that lands on a disambiguation page is counted separately in the output. Each command has a WIKIPEDIA_TITLE_OVERRIDES list for names that do not match the right article, and a new one belongs there.
  • Requests are spaced 0.2 s apart, and a 429 is waited out once, as long as Wikipedia asks.

aircraft:content never replaces a stored summary with nothing: if an article moves and Wikipedia answers 404, the old text stays.

Each summary is credited where it is shown (C30): "From Wikipedia", linked to the article it came from, under CC BY-SA 4.0. Each city photo's author and licence are read from its file page when the photo is kept, and listed on /photo-credits, linked from the footer. A row kept before this has no article link or credit until it is looked up again, so the deploy that brings it runs all three commands once with --force.

They run weekly from the schedule (Sundays at 04:00, 04:15 and 04:30 UTC). They are not part of app:install, for the same reason currency:rates is not: a deploy should not fail because a third party is down.

Currency Management

  1. currency:rates: Refresh the conversion rates every price is converted with.

    php noah currency:rates
    

    Fetches the European Central Bank's reference rates and stores them against the day the ECB published them, so running it twice in an afternoon corrects one row rather than writing two. Safe to put on a daily cron; the ECB publishes on working days, so a weekend run simply records Friday's figures again.

    Nothing is written unless the whole response is usable — a truncated body would otherwise leave some currencies on an older rate while claiming all of them had just been confirmed. To see what would be stored without storing it:

    php noah currency:rates --dry-run
    

    app:install seeds a starting set of rates, so a fresh clone converts before this has ever run. It is not part of app:install on purpose: a deploy should not be able to fail because a third party is down.

Conclusion

Noah CLI simplifies various tasks related to FareSky. By utilizing its commands and their respective options, you can efficiently build and manage application. For more detailed information about each command and its usage, don't hesitate to consult the command's help screen using the help command as demonstrated above.

License

All rights reserved. See LICENSE.txt. What the app uses from others -- seed data, Wikipedia's text, maps, fonts -- is in ATTRIBUTION.md.

Last updated 3 October 2026.