Skip to main content

Documentation

No results found.
Features Members

Job Listings

Post job listings and take applications online. The feature seeds a pre-built Job Listings content type (title, department, location, employment type, description, salary range) with generated /job-listings list and detail pages, and an Emp...

Post job listings and take applications online. The feature seeds a pre-built Job Listings content type (title, department, location, employment type, description, salary range) with generated /job-listings list and detail pages, and an Employment Application form in the form builder — first name, last name, email, phone, position, cover letter, and a resume upload (PDF/DOC/DOCX). The application form renders in every job detail page's sidebar with the position pre-filled, and every submission lands in the form builder's submissions inbox.


What it does

  • A pre-built Job Listings content type: seeded with the fields a listing needs — Title, Department (select), Location, Employment Type (Full-time / Part-time / Contract / Internship), rich-text Description, and Salary Range. It's an ordinary content type, so everything content types do applies: edit the fields, reorder them, add new ones, manage openings from the dashboard sidebar, publish/unpublish per item.
  • Public listing + detail pages: a job-board index at /job-listings — department and job-type filter pills above the openings, each listed as a title link with its location, employment type, salary range, and posted date, beside a sidebar with keyword search and the job-alerts signup — plus a detail page per opening. Both are generated content-type pages, fully editable in the page builder like any other page, and the list design is swappable from the content type's List Page Layout picker.
  • Apply online, on the job page: the default detail layout puts the application form in the page's sidebar, next to a job overview card (location, department, type, pay — each hiding when the field is empty), with the full description filling the main column. The form is a form-builder form embedded via the standard form picker, with the position field pre-filled from the job listing being viewed, so each submission records which role it's for. The standalone Job Application Form row remains in the library for stacking onto any other page.
  • Resume uploads: the seeded application form includes a required file field accepting pdf,doc,docx up to 10 MB. Uploads are stored on the public disk under form-uploads/{form}/{field} and linked from the submission detail view.
  • Applicant tracking through the form builder: submissions are saved (spam-filtered by the site's configured protection), viewable under Dashboard → Forms → Submissions, emailed to the form's notification address when one is set, and — when the CRM is enabled — each applicant becomes/updates a CRM contact with the submission logged on their timeline.
  • Job alerts: a signup form in the listings page's sidebar collects emails from people who found nothing that fits, and every newly published listing emails all of them. Double opt-in, one-click unsubscribe, and a subscriber list under Dashboard → Job Alerts — see Job alerts below.
  • Fully editable: the application form is a normal form-builder form (add fields, change the accept list, switch spam protection); the apply row is a normal design row (change the heading, swap the form, restyle). Nothing is hardcoded.

Toggle it under Settings → Features → Job Listings (off by default).

What enabling does

Turning the feature on runs the Job Listings scaffolder (app/Support/JobListingsScaffolder.php) via the shared FeatureActivator post-enable hook:

  1. Seeds the Employment Application form (form builder, type: job_application) from the canonical FormTemplates::jobApplication() template — idempotent with the demo-data forms seeder, so whichever runs first wins.
  2. Scaffolds the content type + pages through the same path the Tools seeding UI uses (type definition, dashboard CRUD, /job-listings index + detail pages — no sample items). The type's default list layout is the Job Board with Sidebar design (filter pills + listings, with keyword search and the job-alerts signup in the sidebar), and its default detail layout is Job Listing with Apply Form (description beside a sidebar with the application form and the job overview card).
  3. Wires the form into the apply row's form picker (a form_id content override on the row) and recompiles the page-data sidecar, so the public page renders the form immediately.

Every step is idempotent — re-enabling never duplicates the form, the type, or the override. Demo job listings (three sample listings) seed via Tools → Seed demo data like every other demo content type.

Gating (the Events Calendar pattern)

Job Listings is a content-type-shaped capability, so it ships as a seeded content type gated by a feature flag, not a feature module:

  • The job-listings entry in SeedDemoDataJob::demoContentTypes() carries requires_feature => 'job_listings' — the type (and its demo items) never seeds on installs that haven't enabled the feature.
  • The Job Application Form design row is tagged @requiresFeature job_listings + @requiresContentType job-listings, so it only surfaces in pickers on installs with the feature on, and only for job-listings pages.
  • Content types built by hand stay free — the gate covers the pre-built type, the seeded application form, and the apply row, not any platform capability.

The apply flow, end to end

  1. A visitor opens /job-listings/senior-frontend-engineer and finds Apply for this Position in the sidebar, beside the description.
  2. The form renders through the standard public form component (ContactForm), with position pre-filled from the opening's title (the row passes a prefill map through x-dl.form-select; visitors can still edit it).
  3. Submission runs the form's spam protection, validation (the resume field enforces the accept list and size cap — SVG is always stripped from public-form accept lists), then stores the submission, uploads the resume, sends the notification email, records the analytics conversion, and captures the applicant into the CRM.
  4. The hiring team reviews applications under Dashboard → Forms → Submissions (resume linked per submission) or in the CRM contact timeline.

Job alerts

Most visitors to a careers page arrive when nothing that fits is open. Job alerts capture them instead of losing them: they leave an email, and the next time a position is published they hear about it.

Deliberately self-contained. Subscribers live in their own job_alert_subscribers table, not as a tag on marketing_subscribers — Job Listings has to work on an install that never enables the Marketing addon, and asking to hear about jobs is not consent to be marketed to. Nothing about the two lists is shared.

What the visitor sees

  • A Get Job Alerts card in the job board's sidebar on the generated /job-listings page. The full-width CTA - Job Alerts Signup band (design library → Page Content → CTA, gated @requiresFeature job_listings) remains available for any page from the page builder — both post to the same signup route.
  • The form is a plain HTML POST with a honeypot field and a normal @csrf field — no Livewire, no JavaScript, so it works with scripts disabled and costs the listings page nothing. It keeps full CSRF protection even though the page is response-cached: Spatie's CsrfTokenReplacer swaps a fresh per-visitor token into the cached HTML on every hit (see CLAUDE.md, "The response cache and CSRF"). The route is throttled to 15/min on top, because it sends mail.
  • Signing up sends a confirmation email; nothing is ever sent to an address until its owner clicks the link, so a stranger can't subscribe someone else. Confirming, checking your email, and unsubscribing all render resources/views/job-alerts.blade.php through layouts.public, so they wear the site's own header, footer, and branding.

What happens when a listing publishes

App\Support\JobAlerts::maybeAnnounce(), called from the ContentItem::saved hook, mails every confirmed subscriber. The same hook covers a dashboard "publish now" and content:publish-due flipping a scheduled listing live. It's defer()red, so the editor's save never waits on the mail run.

Every guard lives in maybeAnnounce():

Guard Why
Feature on + job_alerts.enabled setting The dashboard switch is a real kill switch.
type_slug === 'job-listings' Other content types never announce.
status === 'published', published_at not in the future Drafts and scheduled items stay quiet until they're actually live.
published_at within FRESH_WINDOW_DAYS (1) Importing or correcting an old posting can't spam the list.
A job_alert_sends row claimed first One email per listing, ever — the receipt is written before any mail goes out, so two concurrent saves race there and the loser sends nothing.

Each alert carries an unsubscribe link and an RFC 8058 List-Unsubscribe header, so mail clients can offer their own unsubscribe button instead of the recipient reaching for "spam". A failing address is reported and skipped — one bad recipient never stops the rest of the list.

Managing the list

Dashboard → Job Alerts (Manage group in the sidebar, hidden when the feature is off) shows subscriber counts, how many listings have been announced and how many emails that took, a searchable/filterable list (subscribed / awaiting confirmation / unsubscribed), a per-row remove, and the Email subscribers on publish switch (job_alerts.enabled, default on).