HATEOAS in PHP in Practice: Building Hypermedia Links Into API Responses
AI generated
<?php
8.4
PHP · API Design · REST · Hypermedia
HATEOAS in PHP in Practice
Hypermedia links instead of hardcoded client URLs

HATEOAS turns a PHP API into a real state machine: instead of clients building URLs themselves, every response ships the links that are actually allowed in the current state. This article shows a practical HATEOAS implementation in PHP with the HAL format, a custom link builder, and concrete examples from an order process.

18 min read HATEOAS · HAL · Hypermedia · API Design PHP 8.4

1. What HATEOAS really means

HATEOAS stands for Hypermedia as the Engine of Application State, and it is the most commonly misunderstood part of Roy Fielding's REST dissertation. Most so called REST APIs only fulfill the basic requirements: resources, HTTP verbs, status codes. HATEOAS goes a step further and requires the client to derive its next step from the response itself, instead of hardcoding knowledge about URL structures into the client code. A response with HATEOAS therefore contains not only data, but also the links that are actually valid in the current state.

In PHP this means concretely: every resource class gets, next to its data, a collection of links, typically under the key _links. An order object in the state "pending" ships a link for cancellation, an order object in the state "shipped" instead ships a link for tracking. The client does not need to know the state transitions, it reads them from the response. This reduces coupling between client and server significantly and makes HATEOAS an effective tool against brittle frontend integrations.

An important distinction: HATEOAS is not a replacement for OpenAPI documentation, and it is not an end in itself. It pays off especially where resources have complex state transitions, such as order processes, workflow engines, or approval chains. For simple CRUD endpoints without state logic, the extra effort is often not justified, so a pragmatic look at the actual benefit belongs at the start of every HATEOAS introduction.

2. The core idea: resources as a state machine

The core of HATEOAS is the notion that a resource has a clearly defined state at any point in time, and that a limited set of allowed transitions follows from that state. An order in the state pending allows cancelling and paying, in the state paid it only allows cancelling with a refund, in the state shipped it exclusively allows tracking the shipment. Mapping these transitions into links is the actual work in a HATEOAS implementation, not merely appending a self URL.

In practice, the cleanest approach keeps state logic where it already lives, usually in a domain class or a state machine object, and asks the HATEOAS presentation layer only one question: which transitions are allowed from here. This separation prevents state logic from being duplicated across two parallel implementations, one for business rules and one for link generation.


<?php

declare(strict_types=1);

/**
 * Domain state machine for an order resource.
 * HATEOAS links are derived from this, never duplicated.
 */
final class OrderState
{
    public function __construct(private readonly string $status)
    {
    }

    /**
     * Returns the list of allowed transitions for the current status.
     *
     * @return string[]
     */
    public function allowedTransitions(): array
    {
        return match ($this->status) {
            'pending' => ['cancel', 'pay'],
            'paid' => ['cancel', 'ship'],
            'shipped' => ['track'],
            'cancelled', 'delivered' => [],
            default => [],
        };
    }

    public function status(): string
    {
        return $this->status;
    }
}

3. HAL as a practical format for HATEOAS responses

HAL, Hypertext Application Language, is the most widely used concrete format for HATEOAS responses in PHP APIs. It defines a simple convention: every JSON resource gets a _links object with named relations, each relation points to an href. Embedded resources move into an _embedded object. This convention is deliberately minimal, which makes HAL easy to integrate into existing JSON APIs without rebuilding the entire response structure.

The advantage of HAL over an ad hoc link structure lies in predictability: client libraries for HAL exist in practically every language, and developers who have seen HAL once understand immediately how to read a new HAL response. For PHP APIs that want to implement HATEOAS seriously, HAL is therefore usually the most pragmatic choice compared to more powerful but also much more elaborate alternatives such as JSON:API or Siren.


{
  "id": 4821,
  "status": "paid",
  "total": 129.90,
  "_links": {
    "self": { "href": "/orders/4821" },
    "cancel": { "href": "/orders/4821/cancel", "method": "POST" },
    "ship": { "href": "/orders/4821/ship", "method": "POST" }
  },
  "_embedded": {
    "customer": {
      "id": 77,
      "name": "M. Schmidt",
      "_links": { "self": { "href": "/customers/77" } }
    }
  }
}

Instead of concatenating links manually everywhere, a small, reusable link builder class pays off. It knows the base URL of the API, can resolve routes by name and parameters, and returns consistently structured HAL link objects. In a Symfony or Slim project you plug in the existing URL generator for this, in a framework free setup a simple route table with placeholders is enough.

The decisive advantage of a central link builder: if a URL prefix or route structure changes later, only one place needs to be adjusted instead of dozens of scattered string concatenations. Especially with HATEOAS responses that have many relations per resource, this centralization pays off quickly, because inconsistent links are among the most common bugs in hypermedia APIs.


<?php

declare(strict_types=1);

/**
 * Minimal link builder producing HAL-style link arrays.
 */
final class HalLinkBuilder
{
    public function __construct(private readonly string $baseUrl)
    {
    }

    /**
     * Builds a single HAL link entry.
     *
     * @param string $path Relative path, e.g. "/orders/4821/cancel"
     * @param string $method HTTP method allowed for this link
     * @return array{href: string, method: string}
     */
    public function link(string $path, string $method = 'GET'): array
    {
        return [
            'href' => rtrim($this->baseUrl, '/') . $path,
            'method' => $method,
        ];
    }

    /**
     * Builds the full _links block for an order based on its allowed transitions.
     *
     * @param int $orderId
     * @param string[] $transitions
     * @return array<string, array{href: string, method: string}>
     */
    public function orderLinks(int $orderId, array $transitions): array
    {
        $links = ['self' => $this->link("/orders/{$orderId}")];

        foreach ($transitions as $transition) {
            $links[$transition] = $this->link("/orders/{$orderId}/{$transition}", 'POST');
        }

        return $links;
    }
}

The actual heart of HATEOAS is the conditional generation of links. A link should only appear in the response if the associated action is actually allowed in the current state. If the link is missing, the client knows without an additional request that the action is not currently available, a simple but effective mechanism that makes client side state checks unnecessary.

In implementation terms this means: HATEOAS serialization does not ask "is there a route for cancelling", it asks "is cancelling allowed for this specific resource in its current state". This distinction is decisive because it lets authorization logic and state logic flow into link generation. An order object therefore shows a regular customer different links than it shows a support agent, even when both are looking at the same order in the same state.


<?php

declare(strict_types=1);

/**
 * Serializes an order into a HAL representation with conditional links.
 */
final class OrderHalSerializer
{
    public function __construct(private readonly HalLinkBuilder $links)
    {
    }

    /**
     * @param OrderState $order
     * @param bool $isSupportAgent Whether extra support-only actions apply
     * @return array<string, mixed>
     */
    public function serialize(OrderState $order, int $orderId, bool $isSupportAgent): array
    {
        $transitions = $order->allowedTransitions();

        if ($isSupportAgent && $order->status() !== 'cancelled') {
            $transitions[] = 'force-refund';
        }

        return [
            'id' => $orderId,
            'status' => $order->status(),
            '_links' => $this->links->orderLinks($orderId, $transitions),
        ];
    }
}

6. Discoverability: navigation instead of hardcoded URLs

An often underestimated benefit of HATEOAS is decoupling the client from the API's URL structure. Without HATEOAS a frontend must know that a cancellation lives under POST /orders/{id}/cancel, this knowledge is hardcoded and breaks with every URL restructuring. With HATEOAS the client instead follows the relation cancel from the response it last loaded, the actual URL becomes irrelevant to the client.

This discoverability pays off especially in multi client environments, when several frontend teams, mobile apps, and third party integrations consume the same API. If the internal route structure changes, every client stays functional as long as it keeps navigating through relation names instead of fixed paths. That makes HATEOAS a practical tool for long lived, publicly consumed PHP APIs.

7. Versioning and API evolution through hypermedia

HATEOAS reduces, but does not replace, a versioning strategy. Where plain data fields or status codes change, hypermedia helps little, a clear contract between client and server is still required for that. Where workflows change, however, for example a new intermediate step in an approval process, HATEOAS shows its strength: the client does not need to know the new step, it simply follows the newly added link, as long as it is not explicitly tied to a hardcoded old flow.

In practice HATEOAS is therefore usually combined with additive changes: new relations can be added at any time without breaking existing clients, because unknown links are simply ignored. Removed or renamed relations, on the other hand, are breaking changes and must be treated like any other breaking change, including a deprecation period and communication to consuming teams.

8. Performance considerations in link generation

Generating links is rarely the bottleneck of a PHP API, but it can become one when list endpoints return hundreds of resources and each resource has to compute its own conditional links. In that case it pays off to keep the per resource state logic as efficient as possible and to batch expensive checks, such as permission checks, for an entire list instead of executing them individually per element.

Another performance aspect concerns embedded resources in the _embedded block: if customer data is embedded with every order, list endpoints quickly turn into N+1 queries. The same pattern that helps with classic eager loading applies here: preload related resources for the entire list in a single database query, instead of loading them per resource before the HATEOAS serialization even starts.

9. HATEOAS compared to plain JSON

The following table contrasts a HATEOAS response with a classic, purely data driven JSON response and shows where the two approaches differ in practice.

Aspect Plain JSON HATEOAS / HAL
Client knowledge of URLs Hardcoded in the client Read from the response
Allowed actions visible Only via separate documentation Directly visible in _links
Implementation effort Low Moderate to high
Robustness on URL changes Breaks clients Clients keep working
Suited for Simple CRUD endpoints Complex workflows, public APIs

The comparison shows that HATEOAS is not a universal solution, but a tool to be applied deliberately for PHP APIs with real state transitions. Where that complexity is missing, the extra effort outweighs the benefit, where it exists, HATEOAS measurably reduces coupling between client and server.

Mironsoft

PHP API design, hypermedia and long lived backend architecture

Want HATEOAS and hypermedia done right in PHP?

We design PHP APIs with HAL links, conditional state logic, and a maintainable link builder architecture, so clients navigate states instead of duplicating URLs.

API architecture review

Analysis of existing endpoints for state logic and HATEOAS potential

HAL implementation

Building link builders, conditional relations, and consistent serialization

Client integration

Supporting frontend and partner teams through hypermedia navigation

10. Summary

HATEOAS in PHP pays off wherever resources have real state transitions: order processes, approval chains, workflow engines. The practical implementation follows a clear pattern: state logic stays in the domain, a link builder generates consistent HAL links, and serialization asks, for every relation, whether the associated action is allowed for the specific user in the current state. This conditional link generation is the actual value of HATEOAS, not merely appending a self URL.

For simple CRUD endpoints without meaningful state logic, the additional implementation effort usually does not pay off, HATEOAS unfolds its value only with more complex, publicly consumed PHP APIs with multiple client teams. Whoever clarifies this point before introducing it avoids unnecessary effort and applies hypermedia specifically where it actually reduces coupling.

HATEOAS in PHP: The Key Points at a Glance

Core principle

Resources ship the links allowed in the current state directly, instead of anchoring URL knowledge in the client.

Format

HAL with _links and _embedded is the most pragmatic choice for PHP APIs, widely supported and simple to implement.

Implementation

A central link builder plus conditional link generation based on state and the permissions of the requesting user.

Use case

Workflows with real state transitions, not simple CRUD endpoints without state logic.

11. FAQ: HATEOAS in PHP in Practice

1What does HATEOAS actually mean?
Hypermedia as the Engine of Application State: the response ships links allowed in the current state, the client does not need to know URL structures itself.
2Is HATEOAS sensible for every API?
No, for simple CRUD endpoints the effort outweighs the benefit. Sensible mainly for workflows with real state transitions.
3What is HAL?
A lightweight JSON format with _links and _embedded, widely adopted and easy to integrate into existing APIs.
4How are links generated based on state?
A domain class returns allowed transitions for the current status, serialization generates links only for those.
5Does HATEOAS replace versioning?
No, it reduces coupling for workflow changes but does not replace a versioning strategy for data fields.
6How to avoid N+1 problems?
Preload related resources for the whole list instead of per element, before HAL serialization runs.
7Should links vary by role?
Yes, permissions should factor into link generation so different user roles see different actions.
8Alternatives to HAL?
JSON:API and Siren are more powerful but more elaborate. HAL remains the most pragmatic entry point for most PHP projects.
9How to structure a link builder?
A central class knows the base URL and produces consistent link arrays, so URL changes only need one place maintained.
10Does HATEOAS hurt performance?
Barely for individual resources, for lists state checks should be batched instead of run per element.