preflow / starter
Preflow Jobs: a job board starter on SQLite, with a public site, employer accounts and a JSON API
Requires
- php: >=8.4
- league/commonmark: ^2.5
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
- preflow/auth: ^0.63
- preflow/components: ^0.63
- preflow/core: ^0.63
- preflow/data: ^0.63
- preflow/devtools: ^0.63
- preflow/form: ^0.63
- preflow/htmx: ^0.63
- preflow/i18n: ^0.63
- preflow/routing: ^0.63
- preflow/twig: ^0.63
- preflow/validation: ^0.63
- preflow/view: ^0.63
Requires (Dev)
- phpunit/phpunit: ^11.0
- preflow/testing: ^0.63
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A small job board to read, run and copy. It shows three things on the same models: server-rendered pages, components that update parts of a page with HTMX, and a JSON REST API. It runs on SQLite, so there is no database server to set up. It has no Node, no npm and no build step.
Quick start
composer create-project preflow/starter myapp
cd myapp
php preflow serve
Open http://localhost:8080. Use that exact address: APP_URL in .env and the CSRF origin check both expect it. If you open the app as 127.0.0.1:8080, the login form will refuse you.
create-project copies .env, makes an app key, creates the database and fills it with demo data. At the end it prints the demo login:
Demo employer: employer@example.com / demo-employer (sign in at /employer)
This password is public demo data. APP_DEBUG=0 only stops the seeder from running; it does not remove an account that already exists. The account lives in the SQLite file in storage/data, so never upload that file from a dev install (see "Deploy on shared hosting").
Tour
| Open | What you see | Where it lives |
|---|---|---|
/ |
Home page with the latest jobs | app/pages/index.twig, app/Components/LatestJobs/ |
/jobs |
Search and filter, 12 jobs per page | app/pages/jobs/index.twig, app/Components/JobSearch/, app/Jobs/JobFilters.php |
/jobs/<slug> |
One job, with more jobs from the same company | app/Controllers/Site/JobController.php, app/pages/_views/job.twig |
/companies/<slug> |
A company and its open jobs | app/Controllers/Site/CompanyController.php, app/pages/_views/company.twig |
/employer |
Sign in, then add, edit, publish and unpublish your jobs | app/Controllers/Site/EmployerAuthController.php, EmployerJobController.php, app/pages/_views/employer/ |
/api/jobs |
The same jobs as JSON | app/Controllers/Api/JobsController.php |
A few more things to know:
- Both the pages and the API read through
app/Jobs/JobSearch.phpand write throughapp/Jobs/JobWriter.php. The rules for a job live once, inapp/Models/Job.php. app/Jobs/JobResource.phpis the one place that decides how a job looks as an array, for the job cards and for JSON.php preflow routes:listlists the routed paths. What middleware answers, such as/sitemap.xmland/robots.txt, isn't listed.
Try this
1. Add a filter. Let visitors filter by a minimum salary.
- Add
salaryMintoJobFilters(app/Jobs/JobFilters.php). It must never throw: turn anything odd into the default. - Add a
where('salaryTo', '>=', ...)toJobSearch::search(). - Add an input to
app/Components/JobSearch/JobSearch.twig. The page, the HTMX search and/api/jobsall pick it up, because they shareJobFilters.
2. Add a field to Job, end to end. For example applyUrl.
- Add a column in a new migration,
php preflow make:migration, thenphp preflow migrate. - Add the property with
#[Field]and#[Validate]toapp/Models/Job.php. - Add the name to
EDITABLEinapp/Jobs/JobWriter.php. - Add it to
API_FIELDSandtoArray()inapp/Jobs/JobResource.php. - Add a form field in
app/pages/_views/employer/form.twigand show it inapp/pages/_views/job.twig.
3. Add an API endpoint. For example GET /api/places, the list of places that have jobs.
- Create
app/Controllers/Api/PlacesController.phpwith#[Route('/api/places')]and a#[Get('/')]method. - Take
JobSearchin the constructor and returnJson::data($jobs->places()). - Run
php preflow routes:listandcurl http://localhost:8080/api/places. Controllers are found by their attributes, so there is nothing to register.
The API
Everything is under /api and answers JSON. Keys are snake_case. A response is {"data": ..., "meta": ...}. An error is {"message": "..."}, and a validation error (422) adds "errors": {"field": ["message"]}. In debug mode errors also carry a debug key. With APP_DEBUG=0 it is gone.
Reading is public:
# Jobs, 12 per page. Filters: q, place, type, remote, page curl "http://localhost:8080/api/jobs?type=full_time&remote=hybrid&page=1" # One job, by its id curl http://localhost:8080/api/jobs/782fa59586f012609c8e476cb0dee275 # Companies, and one company with its open jobs curl http://localhost:8080/api/companies curl http://localhost:8080/api/companies/lumen-harbor
The list answers with a meta block: page, per_page, total, last_page. Odd filter values never cause an error: they fall back to the default, and a page past the last one is an empty list.
Writing needs a token. Create one for an employer from the command line. It is shown once:
php preflow auth:token employer@example.com --name=my-script php preflow auth:token --list employer@example.com php preflow auth:token --revoke=<token-id>
Send it as a bearer token:
TOKEN=<the token> # Create a job. "status" is draft (the default) or published. curl -X POST http://localhost:8080/api/jobs \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"title":"Support Engineer","place":"Zurich","employment_type":"full_time","remote":"hybrid","description":"Help our customers get unstuck, kindly and quickly.","status":"published"}' # Change a job, or publish and unpublish it curl -X PATCH http://localhost:8080/api/jobs/<id> \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"status":"draft"}' # Delete a job curl -X DELETE http://localhost:8080/api/jobs/<id> -H "Authorization: Bearer $TOKEN"
employment_typeisfull_time,part_time,contractorinternship.remoteisno,hybridorfull.descriptionis Markdown, at least 30 characters.- No token gives 401. A missing job gives 404. A job of another company gives 403, not 404. That is on purpose: the ids of jobs you create are random and unguessable, so the answer reveals nothing, and a client gets a clearer message. (The demo seed's ids are not random: they are fixed md5 values, so the demo data is the same on every install.)
- A draft is not visible on the public read routes. Only its owner sees it, in the answer to a write.
Never put cookie-authenticated (session) routes under /api. /api is exempt from CSRF checks in config/csrf.php, because its write routes take bearer tokens only, which a browser never sends by itself. A route that trusts the session cookie under /api would be open to forged requests.
CORS is off. To let a browser app on another origin call the API, list the origin in config/api.php:
'cors' => ['origins' => ['https://app.example.com']],
API only
You can run the app as a pure API. Delete these three paths:
rm -rf app/Components app/pages app/Controllers/Site
The page tests need the pages, so remove them too: delete tests/Site and the Site suite in phpunit.xml. Then composer test still passes: the Unit, Api and ApiOnly suites run against the app without its frontend.
tests/ApiOnlyTest.php proves it without you deleting anything. It copies the project without those three paths, runs the Api suite against the copy, and checks that /jobs is a 404 while /api/jobs still answers.
The page cache
Pages for visitors who are not signed in are kept as finished HTML (config/page_cache.php). It is switched on, but it is off while APP_DEBUG=1, which is what .env has after create-project. That way you always see your latest edits while you work.
To watch it work, set APP_DEBUG=0 in .env, restart php preflow serve, and ask for a page twice. Use a GET (-D -): a HEAD request (curl -I) is never stored.
curl -s -o /dev/null -D - http://localhost:8080/jobs | grep -i x-page-cache # miss curl -s -o /dev/null -D - http://localhost:8080/jobs | grep -i x-page-cache # hit
Publishing or changing a job refreshes the pages that show it. You clear nothing by hand. A signed-in employer is never served from the cache. Searching does not start a session, so a visitor who searches keeps getting cached pages. To empty the cache: php preflow cache:clear pages.
Tests
composer test
This runs PHPUnit with four parts:
Unit: small classes without the app, likeJobFilters.Site: the pages, the search, the employer area, the page cache and the seed.Api: the REST API.ApiOnly:tests/ApiOnlyTest.php, which runs theApisuite on a copy without the frontend.
Run one part with vendor/bin/phpunit --testsuite Api. Every test boots the real app on a temporary copy with its own SQLite file, so storage/data is never touched.
Deploy on shared hosting
- Upload the project and run
composer install --no-dev. Do not uploadstorage/data/*.sqlitefrom a dev install: it holds the public demo account. Production starts with a fresh database, made by the steps below (migrate, thenemployer:create). - Create
.envfrom.env.example. SetAPP_DEBUG=0and a realAPP_URLwith its scheme, likehttps://jobs.example.com. - Run
php preflow key:generateandphp preflow migrate. - Do not run the demo seed. It refuses to run with debug off, and its password is public. Create the first account with
php preflow employer:create you@example.com --company="Your Company". It prints a password once. - Add this cron job. It rebuilds the pages that the page cache marked as outdated:
* * * * * cd /path/to/app && php preflow pages:work - Point the document root at
public/. - Run
php preflow optimize. Run it again after every deploy.
The database is a SQLite file in storage/data. Keep that folder writable, and back it up. To use MySQL instead, change config/data.php: add a mysql driver and make it the default.
Upgrading Preflow
composer update "preflow/*" php preflow migrate php preflow optimize composer test
Read the upgrade notes in the Preflow release before you update.