justinholtweb / craft-hire
Job listings and applications for Craft CMS — post openings, take applications, and move candidates through a hiring pipeline without leaving the control panel.
Package info
github.com/justinholtweb/craft-hire
Type:craft-plugin
pkg:composer/justinholtweb/craft-hire
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 13:13:51 UTC
README
Job listings and applications for Craft CMS 5.
Hire is what WP Job Openings is to WordPress, built the way Craft does things — jobs are real elements, with a field layout, per-site URLs, drafts, revisions, permissions, search, relations and the trash. Applications are elements too, so the inbox is Craft's own element index, with sources, sorting, search, bulk actions and exporting already working.
{{ craft.hire.listing() }}
That is a working careers page — search box, filters, results, pagination — rendered server-side so it works with JavaScript off, is indexable, and every filter is a link you can paste into an email.
{{ craft.hire.applyForm(job) }}
{{ craft.hire.schema(job) }}
That is the application form and the JobPosting structured data Google for Jobs reads.
What you get
-
Jobs are elements. A field layout for the advert itself, per-site URI formats and templates, drafts and revisions so a job can be written before it is published, element search, relations, the trash, and an index with the columns a careers page wants — reference, applications, openings, location, salary, closing date.
-
Applications are elements too. Which means the inbox already has sources, sorting, keyword search across everything the candidate wrote, a bulk "move to stage" action, and Craft's own exporter — none of it reimplemented.
-
Structured data that actually validates. An invalid
JobPostingfails silently: it is not flagged anywhere a site owner looks, it simply never appears in the jobs carousel. Hire builds it rather than leaving it to a template — including the two rules everybody gets wrong, that a remote job needsjobLocationTypeandapplicantLocationRequirements, and thatemploymentTypehas a fixed vocabulary your own wording has to be mapped onto. -
A form builder. Name, email, phone, CV and cover letter are built in and map onto real columns, because Hire has to be able to email the applicant, spot a duplicate and key a CSV. Everything else is a question of your own — dropdowns, checkboxes, dates, numbers, consent, headings, file uploads — validated on the server, stored as JSON, and read back next to the question that produced it.
-
A hiring pipeline. Stages you design, each declaring whether it means still in the running, hired, or not proceeding — which is what lets a job's filled count move by itself when somebody is hired, and lets "open applications" be a meaningful filter. Ratings, notes, and an append-only history of who moved whom and when.
-
Emails that reach people. A confirmation to the applicant and a notification to the hiring side, both editable as ordinary Craft system messages and translatable per site. Per-stage emails, so moving a candidate to "Interview" is what sends the invitation. Everything queued by default, because sending inside the request means a slow mail server presents to the applicant as a form that hangs.
-
Spam defences that cost an applicant nothing. A honeypot, a signed timestamp, and a rate limit — in that order, before any CAPTCHA is considered. A CAPTCHA is available (reCAPTCHA v2 and v3, hCaptcha, Turnstile) and off by default, because it is a tax on disabled applicants and a third-party script on a page where somebody is typing their employment history.
-
Uploads that are checked twice. The extension against the site's list, and the file's detected type against the extension — a script renamed
payload.pdfclears the first test and fails the second. Filenames are prefixed with a random token so that two candidates who both called their filecv.pdfcannot download each other's, and CVs are streamed through the control panel rather than linked to directly. -
A retention policy that actually deletes. Applications past the retention period are hard deleted, CV included — because a policy that leaves the data in the trash is not a policy. IP addresses are off by default. There is an erasure button that erases.
-
A JSON endpoint at
/hire/jobs.json, anonymous and cacheable, returning exactly what the built-in listing would have shown — same filters, same semantics — so a bespoke front end and the supplied one can never disagree. -
CSV export with a UTF-8 BOM (so Excel on Windows does not mangle every accented name) and formula injection defused (so a candidate who types
=HYPERLINK(…)into a text box has not written code that runs on the recruiter's machine).
Requirements
- Craft CMS 5.3 or later
- PHP 8.2 or later
Installation
composer require justinholtweb/craft-hire php craft plugin/install hire
A fresh install creates the application form everybody recognises (name, email, phone, CV, a note), a four-stage pipeline, and the three classification axes every careers page has ever used — Department, Job type and Location. You can publish a job and take a real application without opening the settings.
Editions
Lite is a working careers page, not a demo. There is deliberately no cap on jobs or on applications: charging for the thing that grows with a company's bad luck is the wrong shape of pricing for a hiring tool. Posting jobs, taking applications, the CV upload, the confirmation and notification emails, the CSV export, the filters, the JSON endpoint and Google for Jobs are all in Lite, uncapped.
Pro is the applicant tracking half — the part that only matters once more than one person is reading the applications.
| Lite | Pro | |
|---|---|---|
| Jobs, applications, CV uploads | Unlimited | Unlimited |
| Job pages, listing, filters, search | ✅ | ✅ |
| JobPosting structured data | ✅ | ✅ |
| Applicant + team emails | ✅ | ✅ |
| CSV export, JSON endpoint | ✅ | ✅ |
| Application forms | 1 | Unlimited |
| Questions of your own | — | ✅ |
| Pipeline stages | The 4 built in | Design your own |
| Stage emails | — | ✅ |
| Ratings and reviewer notes | — | ✅ |
| Duplicate detection | — | ✅ |
| Automatic retention deletion | — | ✅ |
| Applicant status page | — | ✅ |
Templating
The listing
{{ craft.hire.listing() }}
{{ craft.hire.listing({ heading: 'Open positions', limit: 10, search: false }) }}
Or take the data and build your own:
{% set result = craft.hire.results({ limit: 10 }) %}
{% for job in result.jobs %}
<a href="{{ job.url }}">{{ job.title }}</a> — {{ job.locationLabel }}
{% endfor %}
Querying jobs
craft.hire.jobs is an ordinary element query, so everything you already know works:
{% set jobs = craft.hire.jobs
.status('open')
.spec({ department: ['engineering'], jobType: ['full-time'] })
.orderBy('hire_jobs.postDate desc')
.all() %}
Filters within a group are OR; filters across groups are AND — which is what somebody ticking "Engineering" and "Design" means, and what they mean by ticking "Engineering" and "Part time".
A job page
{% block head %}{{ craft.hire.schema(job) }}{% endblock %}
<h1>{{ job.title }}</h1>
<p>{{ job.locationLabel }} · {{ job.salary }} · closes {{ job.expiryDate|date('j F Y') }}</p>
{{ craft.hire.applyForm(job) }}
Changing the markup
Every template Hire renders is an ordinary Twig file you can copy into your own templates/hire/
and edit — Hire looks for the site's copy first. No overrides file, no theme layer, and no reason
to fork the plugin over a class name.
| Copy this | To change |
|---|---|
_listing/listing.twig |
the whole careers page |
_listing/_filters.twig |
the filter panel |
_listing/_job.twig |
one row of the listing |
_form/form.twig |
the application form |
_form/_field.twig |
one question |
_portal/status.twig |
the applicant's status page |
The JSON endpoint
GET /hire/jobs.json?department=engineering&jobType=full-time&q=php&page=2
Anonymous, cacheable, CORS-open, and the same Listing call the built-in page uses.
Console commands
php craft hire/jobs/expire --dry-run # close jobs past their closing date php craft hire/jobs/recount # rebuild the application counters php craft hire/applications/export --file=out.csv --job-id=12 php craft hire/applications/prune --days=365 --dry-run php craft hire/applications/resend 412 # after the SMTP credentials were wrong
Notes
- Phone numbers in the CSV are prefixed with an apostrophe when they start with
+. That is deliberate: Excel treats a leading+as the start of a formula, and+cmd|'/c calc'!A0is a real attack. Spreadsheets consume the apostrophe; it is only visible in the raw file. - Put CVs in a private volume. A CV uploaded by a stranger is personal data belonging to somebody who has not been hired, and a guessable URL on a public volume is a breach waiting for somebody to iterate the filenames. Hire's own download link goes through the control panel and requires the permission.
- Reading applications is a separate permission from managing jobs, and not nested under it. Publishing an advert is not the same as seeing named people's CVs, addresses and employment history, and a great many organisations want the second without the first.
License
Proprietary. See LICENSE.md.