Hire for Craft CMS

Templating

The listing

{{ craft.hire.listing() }}

That is a working careers page — search box, filters, results, pagination — rendered on the server, so it works with JavaScript off, is indexable, and every filter is a link you can paste into an email. A small script enhances it; it never creates content the server didn't render.

Options:

{{ craft.hire.listing({
    heading: 'Open positions',
    limit: 10,
    search: false,
    orderBy: 'expiryDate asc',
    workplaceType: 'remote',
}) }}
Option
headingA heading above the listing
limitJobs per page (default from Settings → General)
searchShow the search box
orderBypostDate, expiryDate, applications, title, dateCreated, dateUpdated or a custom field handle, then asc or desc
filtersFixed filters, as { groupHandle: ['term-slug'] }
workplaceType, formId, specId, siteIdPassed straight to the job query

Closed jobs are left out unless Keep closed jobs in the listing is on.

The data without the markup

{% set result = craft.hire.results({ limit: 10 }) %}

<p>{{ result.total }} jobs</p>
{% for job in result.jobs %}
    <a href="{{ job.url }}">{{ job.title }}</a> — {{ job.locationLabel }}
{% endfor %}

results() is the same call the built-in listing and the JSON endpoint make, so the three can never disagree.

Querying jobs

craft.hire.jobs is an ordinary element query:

{% set jobs = craft.hire.jobs
    .status('open')
    .spec({ department: ['engineering'], jobType: ['full-time'] })
    .orderBy('hire_jobs.postDate desc')
    .all() %}

Within a group, filters are OR; across groups, AND — see Specifications and filtering.

A job page

Set the template for each site under Settings → Job URLs. The job is available as job:

{% block head %}{{ craft.hire.schema(job) }}{% endblock %}

<h1>{{ job.title }}</h1>
<p>{{ job.locationLabel }} · {{ job.salary }} · closes {{ job.expiryDate|date('j F Y') }}</p>

{{ job.description }}  {# a field of your own #}

{{ craft.hire.applyForm(job) }}

applyForm() renders nothing when the job takes applications by URL, email or not at all — use job.applyUrl for the first two.

Structured data

craft.hire.schema(job) outputs the JobPosting JSON-LD that Google for Jobs reads. Hire builds it rather than leaving it to a template, because an invalid posting fails silently — nothing is flagged anywhere a site owner looks; the job simply never appears. It includes datePosted, validThrough, identifier, directApply, baseSalary, employmentType, occupationalCategory, and both halves of a remote posting: jobLocationType and applicantLocationRequirements.

Fill in the organisation name, URL, logo, default country and currency under Settings → General. craft.hire.listSchema(jobs) outputs an ItemList for a listing page.

Changing the markup

Every template Hire renders is an ordinary Twig file. Copy it into your own templates/hire/ and edit it — Hire looks for the site's copy first. No overrides file, no theme layer.

Copy thisTo change
_listing/listing.twigthe whole careers page
_listing/_filters.twigthe filter panel
_listing/_job.twigone row of the listing
_form/form.twigthe application form
_form/_field.twigone question
_portal/status.twigthe applicant's status page

Writing your own form

If you hand-write the form instead, it must post to hire/apply/submit with the jobId, and carry the signed timestamp — a form posted without one is refused by the timing check:

<form method="post" enctype="multipart/form-data">
    {{ csrfInput() }}
    {{ actionInput('hire/apply/submit') }}
    {{ hiddenInput('jobId', job.id) }}
    {{ hiddenInput('hireStamp', craft.hire.formTimestamp()) }}
    …
</form>

Starting from a copy of _form/form.twig is easier, and keeps the honeypot and any CAPTCHA.

Everything else on craft.hire

craft.hire.job(id)One job, by ID, reference (JOB-104) or slug
craft.hire.applicationsAn application query
craft.hire.specGroups, specGroup(handle), specs(handle)Specification groups and terms
craft.hire.specCounts()Job counts per term
craft.hire.stagesPipeline stages
craft.hire.form(handle)An application form
craft.hire.settingsPlugin settings