Back to Journal
erpen

API-only ERP — putting a REST layer in front of legacy PHP

Thin REST layer in front of the legacy PHP/Drupal ERP — 6 endpoint groups, API key, rate limit, idempotency, CP1250 transcoding.

Not every customer can migrate now. Some can't afford the cash-flow hit of an 8–12 week swap. Some have processes so unusual that a modern general-purpose system would, at first, fit them worse. For those tenants we built the API-only pattern: the legacy PHP/Drupal ERP stays as the single source of truth, but we put a thin modern REST layer in front of it.

What it's good for, what it isn't

Good:

  • Plug modern frontends (Storefront, mobile, partner portal) onto the ERP without touching the Drupal core.
  • Expose ERP data to external systems (NAV, banking, BI) much more easily.
  • Let the modern stack's chat assistant (Nortinia Engine) query the legacy ERP.

Not good:

  • NO new business logic lives here. New logic is built in the modern stack. The REST layer is a mirror, not a place for business rules.
  • Bulk writes (10k+ row imports) still go through the legacy admin UI.

The six endpoint groups

Every API-only deployment we ship gets these six out of the box:

  1. CustomersGET /api/v1/customers, GET /:id, POST (allowlisted tenants only), PATCH likewise.
  2. Products — product-master reads + stock lookup.
  3. Orders — sales orders, status reads, status transitions.
  4. Invoices — issued invoices, PDF generation.
  5. Stock — point-in-time stock snapshot, per warehouse.
  6. Reports — predefined reports run with parameters.

Writes are off by default. Per-tenant approval flips them on, and every mutation is audited.

The three operational pillars

API-key auth. Every call carries X-Api-Key: <key> scoped to a tenant. Rotated every 90 days.

Rate limit. Default 60 req/min/tenant. Burst allowance up to 300 on request. The legacy DB cannot take more, and the layer exists precisely so a runaway mobile-app retry loop does not nuke the Drupal install.

Idempotency key. Every POST/PATCH expects an Idempotency-Key header. The response is cached for 24 hours. This is the only defense against network flakes that does NOT double-insert into the legacy DB — Drupal has no idea it exists, and never will.

The hidden trap: CP1250

The legacy DB stores text columns in CP1250. Has done since the 2008 Drupal 6 install. The modern REST API expects UTF-8. Solution: on-the-fly transcoding on every read (PHP's iconv), and back on every write. Two interesting consequences:

  1. A handful of accented characters technically valid in CP1250 are not canonical Hungarian (e.g. some old Drupal rows store \u0151 instead of ő). A small sanitiser runs on every read.
  2. Searching with LIKE %xy% will not work accent-insensitively against the legacy DB; the REST layer either filters in-memory (small lists) or refuses (large lists) — so the frontend cannot offer full-text search, only prefix sets.

Why we don't fork legacy data

We keep one source of truth: the legacy DB. The REST layer does not persistently cache data, only short in-memory TTLs (60s for products, 15s for stock). Duplicating data would mean keeping two stores in sync — exactly the opposite of why we adopt API-only.

Where this leads

API-only is not a dead end. Modern UI components already work against the legacy ERP through the REST layer; when the tenant is ready to migrate, the frontend stays exactly the same — only the backend switches. The REST contract is stable, so the migration is invisible at the UI level.