Personio-Vacancy-Integration: Stellenausschreibungen aus dem Personio-XML-Feed spiegeln
solcom-gmbh.jobs.personio.de/xml) stündlich via migrate_plus. Vacancies erscheinen automatisch unter /karriere mit Canvas-Detail-Page, JSON-LD (schema.org/JobPosting) und Sitemap-Coverage. Verschwundene Jobs werden hart gelöscht. Apply-CTA verlinkt extern auf Personio. Status: draft · Erstellt: 2026-05-02 · ADRs: 0012, 0013, 0014, 0015
Problem Statement
Recruiting-/Marketing-Verantwortliche pflegen Stellen vollständig in Personio. Auf der Solcom-Website gibt es keine automatische Karriere-Übersicht — Bewerbende müssen direkt zur Personio-Career-Page springen (Journey-Bruch) oder Stellen werden redundant in Drupal gepflegt (Drift, Pflege-Aufwand). Außerdem fehlt JSON-LD nach schema.org/JobPosting, wodurch Google-for-Jobs-Rich-Results nicht erreichbar sind.
Stimme aus Recruiting
„Unsere offenen Stellen pflegen wir vollständig in Personio. Auf der Solcom-Website gibt es aktuell keine Karriere-Übersicht, die diesen Bestand automatisch zeigt — Bewerbende müssen entweder direkt zur Personio-Career-Page springen (das fühlt sich wie ein Bruch in der Customer-Journey an) oder wir pflegen Stellen redundant in Drupal nach. Wir wollen, dass Solcom-Besuchende die offenen Stellen auf solcom.de selbst sehen — gefiltert nach Standort und Bereich, mit brand-konformer Detail-Page, in Google für JobPosting-Rich-Results indexierbar — ohne dass jemand Inhalte pflegen muss."
Solution
Eigenes Drupal-Modul solcom_vacancy unter web/modules/custom/: Bundle, Vocabularies, Migrate-Plugins, JSON-LD-Service, hook_node_view-Adapter, Drush-Command-Wrapper, Logger-Channel. Render-Schicht via Canvas-Content-Templates (vacancy.full, vacancy.card). Ingestion via migrate_plus mit XML-Source-Plugin, Cron + Drush-Trigger. Listing: View vacancies_listing unter /karriere mit better_exposed_filters.
/karriere. Inaktive Stellen verschwinden automatisch. Google findet Detail-Pages über Sitemap und versteht sie als JobPosting.Goals (V1)
- Content-Type
vacancyinconfig/sync/definiert, überdrush cimreproducible deploybar. - 10 Vocabularies (
vacancy_office,vacancy_department,vacancy_subcompany,vacancy_recruiting_category,vacancy_employment_type,vacancy_seniority,vacancy_schedule,vacancy_years_experience,vacancy_keyword,vacancy_occupation_category) viaentity_generateautomatisch befüllt. - Modul
solcom_vacancyim Default-Sync aktiviert. - Cron-getriggerte Migration
solcom_vacancyläuft stündlich, importiert beide Subcompanies, idempotent über Personio-id. - Hard-Delete-Lifecycle: Jobs die nicht mehr im Feed erscheinen werden hart gelöscht. Bei Empty-Feed oder HTTP-Fehler: Abbruch ohne Delete, Watchdog-Error.
- Listing-Page
/karriereals Canvas-Page mit Views-Block undbetter_exposed_filters(Office, Department, Subcompany, Employment Type, Seniority). - Detail-Page als Canvas-Content-Template
node.vacancy.full(Hero, Badges, Body, Apply-CTA, Salary-Info). - JSON-LD (
schema.org/JobPosting) als<script type="application/ld+json">im<head>jeder Detail-Page. - Sitemap-Coverage via
simple_sitemap;metatagliefert<meta description>. - Apply-CTA verlinkt auf
https://solcom-gmbh.jobs.personio.de/job/{personio_id}(target=_blank, rel=noopener). - 4 ADRs (0012–0015) dokumentieren Architektur-Entscheidungen.
- Tests: Migrate-Source-Plugin (Kernel), Custom-Process-Plugin (Unit), JSON-LD-Service (Kernel), Listing-Page (Functional), Filter-AJAX (FunctionalJavascript).
User Stories
- Als Recruiterin will ich eine Stelle in Personio freigeben, damit sie spätestens eine Stunde später auf
/karriereerscheint. - Als Recruiterin will ich einen manuellen Sofort-Refresh per Drush auslösen können (
ddev drush migrate:import solcom_vacancy --update). - Als Recruiterin will ich eine Stelle offline nehmen, damit sie nach dem nächsten Import vollständig verschwindet (Listing + Detail + Sitemap + JSON-LD).
- Als Bewerber:in will ich auf
/karrierealle offenen Solcom-Stellen sehen (inkl. House of SOLCOM). - Als Bewerber:in will ich nach Office filtern (Hamburg, Stuttgart, Reutlingen, München, Karlsruhe).
- Als Bewerber:in will ich Filter kombinieren können (z. B. „Stuttgart × Vertrieb × Festanstellung").
- Als Bewerber:in will ich auf der Detail-Page den vollständigen Stellentext in lesbarer Sektionierung sehen.
- Als Bewerber:in will ich einen prominenten „Jetzt bewerben"-Button sehen, der mich auf Personio führt.
- Als Bewerber:in via Google will ich, dass Solcom-Stellen in den Google-for-Jobs-Rich-Results erscheinen.
- Als Solcom-Ops will ich Importfehler in Watchdog nachsehen und sicher sein, dass ein Empty-Feed nicht alle Stellen löscht.
Implementation Decisions
Datenmodell — Content-Type vacancy
| Feld | Typ | Personio-Quelle | Pflicht |
|---|---|---|---|
title | String | <name> | ja |
field_personio_id | String, indexed | <id> | ja |
field_apply_url | Link | konstruiert aus id | ja |
field_body | Long-Text (full_html) | <jobDescriptions> konkateniert | ja |
field_office | Term-Reference (vacancy_office) | <office> | ja |
field_department | Term-Reference (vacancy_department) | <department> | ja |
field_employment_type | Term-Reference | <employmentType> | ja |
field_seniority | Term-Reference | <seniority> | ja |
field_salary_min/_max/_currency/_type | Decimal/List | <salaryInformation> | nein |
Vocabulary vacancy_office erhält zusätzlich field_image (Media-Reference) für Hero-Bilder. Alle Taxonomies via entity_generate-Process.
Migration-Mechanik
- Source-Plugin:
urlmitdata_fetcher: http,data_parser: simple_xml, Item-Selector/workzag-jobs/position. - Custom Process-Plugin
solcom_vacancy_descriptions_concat: konkateniert<jobDescriptions>-Sektionen zu HTML. - Destination:
entity:nodemitdefault_bundle: vacancy. - Cron-Schedule: 3600 Sekunden. Drush-Override:
ddev drush migrate:import solcom_vacancy --update.
Hard-Delete + Edge Cases
- Normaler Lauf: Sweep löscht alle Nodes, deren Personio-ID nicht in der Run-Set-Liste war.
- Empty-Feed (0 Items): Sweep wird abgebrochen, Watchdog-Warning, bestehende Nodes bleiben.
- HTTP-Fehler / Timeout: Migration bricht mit Status
failedab, kein Delete.
Listing-Page /karriere
Canvas-Page mit eingehängtem Views-Block vacancies_listing:block_listing. View: Bundle vacancy, Status published=1, Sort created DESC, 20 Items per Page. BEF als Multi-Select-Pillen. Views-AJAX. Kein HTMX (Skill-Verbot).
Detail-Page
Canvas-Content-Template node.vacancy.full: Breadcrumb → Hero (SDC mit Badges) → Body (text-SDC) → Salary-Box (conditional) → Apply-CTA. Topologie via customer-lens-reviewer zu validieren vor Code.
JSON-LD-Builder
Service-Klasse VacancyJsonLdBuilder::build(NodeInterface). Output: schema.org/JobPosting mit Pflichtfeldern inkl. directApply: false. Injection via hook_node_view → #attached.html_head. Cache-Tags: node:<nid> + Term-Tags.
Modul-Layout solcom_vacancy
web/modules/custom/solcom_vacancy/
├── solcom_vacancy.info.yml
├── solcom_vacancy.module
├── solcom_vacancy.services.yml
├── src/
│ ├── Service/VacancyJsonLdBuilder.php
│ ├── EventSubscriber/VacancyHardDeleteSubscriber.php
│ └── Plugin/migrate/process/VacancyDescriptionsConcat.php
└── tests/src/
├── Unit/VacancyDescriptionsConcatTest.php
└── Kernel/
├── VacancyMigrationTest.php
├── VacancyHardDeleteTest.php
└── VacancyJsonLdBuilderTest.php
Customer-lens-reviewer (Pflicht-Schritt vor Implementation)
Offen: (1) Listing-Card-SDC: card adaptiert vs. dedizierte vacancy-card. (2) Detail-Hero-SDC: hero-blog adaptiert vs. neue vacancy-hero. (3) Apply-CTA: cta vs. dedizierter SDC. (4) Salary-Box: eigene SDC vs. generischer Formatter. Review vor erstem Code-Touch Pflicht.
Testing Decisions
| Test | Typ | Coverage-Ziel |
|---|---|---|
VacancyDescriptionsConcatTest | Unit | Sektions-Slug, HTML-Wrapping, leere Inputs |
VacancyMigrationTest | Kernel | Guzzle-MockHandler, Nodes mit title, field_personio_id, field_office |
VacancyHardDeleteTest | Kernel | Item verschwindet → gelöscht; Empty-Feed → kein Delete; HTTP-Fehler → kein Delete |
VacancyJsonLdBuilderTest | Kernel | JSON-LD-Struktur, directApply: false, baseSalary conditional |
VacancyListingTest | Functional | /karriere lädt, Filter funktioniert, Pagination |
VacancyListingAjaxTest | FunctionalJavascript | BEF-AJAX, URL-Querystring synchron |
VacancyDetailTest | Functional | Title, Body, Apply-CTA, JSON-LD-Script-Tag im Head |
Test-Befehl: ddev drush phpunit web/modules/custom/solcom_vacancy/
ADRs
ADR-0012 — Personio-Vacancy-Spiegel-Modell + Hard-Delete
Entscheidung: Personio = Source-of-Truth, Drupal = Read-only-Spiegel. Hard-Delete bei Verschwinden. Empty-Feed-Schutz verhindert Total-Wipe. Rejected: Drupal editierbar (Konflikt-Logik), Hybrid-Schema, Soft-Archive mit validThrough. Consequences: Drupal-Editor:innen können Vacancies nicht ändern; verschwundene Jobs → 404.
ADR-0013 — Migrate API + migrate_plus als Ingestion-Pattern
Entscheidung: Migrate API + migrate_plus (XML-Source) + migrate_tools (Cron+Drush). Cron 3600s. Rejected: Custom-QueueWorker (Idempotency selbst bauen), Feeds-Modul (D11 weniger gepflegt). Consequences: Setzt Standard für zukünftige externe Quellen.
ADR-0014 — Vacancy-Klassifizierung als Taxonomy
Entscheidung: Alle Klassifizierungs-Achsen als Taxonomy. entity_generate legt fehlende Terms automatisch an. Rejected: Hybrid Taxonomy+List-String, Alles List-String. Consequences: Konsistente Filter-UX, Translatability, Erweiterbarkeit ohne Code-Change.
ADR-0015 — JSON-LD via Custom-Service (statt schema_metatag)
Entscheidung: Custom-Service VacancyJsonLdBuilder + hook_node_view-Adapter. Rejected: schema_metatag (zu großer Footprint), SDC-Komponente (JSON-Building in Twig klobig). Consequences: Voller Test-Coverage via Kernel-Test.
Out of Scope (V1)
- Bewerbungs-Formular auf Drupal — nur externer Link auf Personio.
- Mehrsprachigkeit — nur Deutsch. Englisch wäre Phase-2.
- Interne Suche über Stellen (Search-API-Integration).
- Webhook von Personio — Polling-Modell bleibt.
- Apply-Tracking via UTM.
- Newsletter-Subscription „Job-Alerts".
Open Implementation Questions
- SDC-Topologie: →
customer-lens-reviewer-Konsultation als erster Implementation-Schritt (Pflicht). - Pathauto-Pattern: Vorschlag: flach
/karriere/[node:title]. employment_typezu schema.org-Enum:full-time→FULL_TIME,part-time→PART_TIME, sonstOTHER.- Cache-Tags-Granularität:
node_list:vacancyfür Listing;taxonomy_term:<tid>für Detail-Page.
FAQ
- Was passiert, wenn der Personio-Feed down ist?
- Die Migration bricht mit Status
failedab. Watchdog-Error. Bestehende Nodes bleiben unverändert — kein Delete-Sweep ohne erfolgreichen Import. - Kann ein Drupal-Editor eine Vacancy manuell bearbeiten?
- Nein. Personio ist Source-of-Truth. Drupal-seitige Änderungen würden beim nächsten Import überschrieben.
- Wie schnell erscheinen neue Stellen auf /karriere?
- Spätestens nach 60 Minuten (Cron-Intervall). Bei Bedarf manuell via
ddev drush migrate:import solcom_vacancy --updatesofort. - Werden Stellen von House of SOLCOM auch importiert?
- Ja. Der Feed enthält beide Subcompanies;
field_subcompanyunterscheidet sie. Der Listing-Filter zeigt beide, lässt aber nach Subcompany filtern.