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 and the Airside posts 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.

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.

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.

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.

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-labels.yml, which fails a pull request carrying none or several. (Add it as a required status check on develop for that to block 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 the Trip Builder Project. 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 your Trip Builder Project 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.

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 the Trip Builder Project. 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.

Contributing

If you have a suggestion that would make TripBuilder better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".

  1. Fork the TripBuilder
  2. Create your Feature Branch (git checkout -b feature/SuggestionFeature)
  3. Commit your Changes (git commit -m 'Add some SuggestionFeature')
  4. Push to the Branch (git push origin feature/SuggestionFeature)
  5. Open a Pull Request

License

Distributed under the MIT License. See LICENSE.txt for more information.

Last updated 17 September 2026. Read it on GitHub alongside the source.