Webservices: the /api pattern

REST hands you four blunt verbs, a zoo of 37 versioned endpoints, and a DELETE that returns nothing useful. Tiger sidesteps it. The philosophy (from Beau Beauchamp's "REST is Dying"): your use case should drive the technology, not the other way around.

One endpoint. The message says where it's going.

There is one endpoint — POST /api — and the routing lives inside the message:

await fetch('/api', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
        module:  'billing',      // → Billing_Service_Invoice
        service: 'invoice',
        method:  'create',       // → its create($params) method
        amount:  '4200',         // …the payload just rides along
    }),
});

No path to design. No verb to argue about. No new route to register. The message is the argument.

Add a method → you shipped an endpoint

A service extends Tiger_Service_Service. The method the message names receives the whole payload and writes the response:

class Billing_Service_Invoice extends Tiger_Service_Service
{
    public function create(array $params): void
    {
        if (!$this->_isAdmin()) { $this->_error('billing.error.denied'); return; }

        $form = new Billing_Form_Invoice();
        if (!$form->isValid($params)) { $this->_formErrors($form); return; }   // inline field errors, free

        $id = $this->_transaction(function ($db) use ($params) {
            // …inserts/updates; throw to abort + roll back…
            return $newId;
        });

        $this->_success(['id' => $id], 'billing.invoice.created');
    }
}

That's the whole endpoint. No routing file, no controller, no OpenAPI ceremony. And you got a lot for free: every call is ACL-gated (resource = the class, privilege = the method name), validated, and transactional. The canonical flow is always validate a form → wrap mutations in a transaction.

One response, always the same shape

Every call returns the same envelope — a real contract between client and server:

{ "result": 1,
  "data": { "id": "019f…" },
  "redirect": null,
  "form": null,
  "messages": [ { "message": "Invoice created.", "class": "success", "field": null } ] }

result is 1/0. messages[].class maps straight to a Bootstrap alert context, so the client renders feedback without inspecting the content. Field errors land in form. Redirects, inline validation, DataTables grids ({draw, recordsTotal, …, data}) — all the same envelope.

The phenomenal cosmic power

Because routing lives in the message and the response is a fixed contract, the same /api feeds every front end: the SSR theme you ship today, a React SPA tomorrow, a mobile app next quarter. The UI is always just a client. Add a method, and it's live for all of them — no endpoint zoo to maintain, no versions to juggle, less code to write and break. 🧞

See also

  • API discovery — the OpenAPI catalog generated from your /api surface.
  • API tokens — authenticating a stateless, non-browser client (Bearer).
  • Authorization — the deny-by-default ACL that gates every service.