REST APIs for Backoffice Systems vs. Public APIs: Designing Them Differently
AI generated
{ }
GET
REST API · Backoffice · Public API · Symfony · PHP
REST APIs for Backoffice vs. Public,
designed differently

A backoffice API and a public API share HTTP as transport, but their requirements for stability, backward compatibility, security, and error models differ so fundamentally that a unified design inevitably makes one of the two worse.

12 min read Authentication · Versioning · Rate Limiting · Error Models Symfony 7 · PHP 8.4 · REST

1. Why Backoffice and Public APIs Are Fundamentally Different

The most common mistake in API design is designing a single API that serves both internal backoffice requirements and external developers. That sounds like good adherence to the DRY principle, but in practice it produces a poor compromise for both sides. A backoffice API serves known, controlled clients, typically an internal admin panel, an ERP system, or a mobile app developed by your own team. A public API has to deal with unknown clients, undocumented usage patterns, and a wide range of programming languages and platforms that nobody can fully anticipate.

The second major difference lies in who controls change. For a backoffice API, a team can simply decide: "We are changing this endpoint tomorrow, and all clients will be updated at the same time." For a public API, that is impossible. External developers rely on stability, have their own deployment cycles, and cannot react to an API change within 24 hours. This different pace of change runs through every design decision that follows, and it is the reason a unified API for both use cases fails.

2. Target Audience as the Primary Design Driver

Every good API design decision starts with the question: "Who calls this endpoint, and what do they actually need?" For a backoffice API, the answers are precise: the caller is a known internal system with defined permissions, it often needs more detailed data than an external consumer, and it tolerates more complex request structures because the calling team speaks the same domain language. A backoffice endpoint for order details can easily return internal data such as warehouse location, purchase price, and internal notes.

A public API, on the other hand, has to account for data protection concerns and must never let internal data leak into responses. The target audience consists of external developers who may never have had a personal conversation with the API design team. Response fields must be self-explanatory, error messages must be understandable without requiring internal system knowledge, and the documentation has to be complete because there is no Slack channel to ask follow-up questions in. These cognitive requirements shape the entire API design from the ground up.


<?php
// Backoffice API Controller: rich internal data, no public exposure
#[Route('/api/backoffice/orders/{id}', methods: ['GET'])]
public function getOrderDetail(
    int $id,
    OrderRepository $orders,
    #[CurrentUser] User $admin,
): JsonResponse {
    $this->denyAccessUnlessGranted('ROLE_BACKOFFICE');

    $order = $orders->findWithInternalDetails($id);

    return $this->json([
        'id'              => $order->getId(),
        'status'          => $order->getStatus()->value,
        'customer_email'  => $order->getCustomer()->getEmail(),
        'purchase_price'  => $order->getInternalPurchasePrice(), // internal only
        'margin_percent'  => $order->getMarginPercent(),         // internal only
        'warehouse_slot'  => $order->getWarehouseSlot(),         // internal only
        'internal_notes'  => $order->getInternalNotes(),         // internal only
        'created_at'      => $order->getCreatedAt()->format('c'),
    ]);
}

// Public API Controller: filtered, safe, documented fields only
#[Route('/api/v1/orders/{id}', methods: ['GET'])]
public function getPublicOrderDetail(
    int $id,
    OrderRepository $orders,
    Security $security,
): JsonResponse {
    $order = $orders->findPublicById($id);

    // Ensure only the owner can access
    if ($order->getCustomerId() !== $security->getUser()?->getId()) {
        throw new AccessDeniedException();
    }

    return $this->json([
        'id'         => $order->getId(),
        'status'     => $order->getStatus()->value,
        'items'      => $this->serializeItems($order->getItems()),
        'total'      => $order->getTotal(),
        'created_at' => $order->getCreatedAt()->format('c'),
        // No internal fields, no pricing details, no warehouse data
    ]);
}

3. Authentication: Session vs. API Key vs. OAuth2

Authentication is one of the clearest areas where backoffice and public APIs need different solutions. A backoffice API that is only called from the company's own admin panel can work perfectly well with session cookies or a simple bearer token bound to an internal user object. It is easy to implement, easy to revoke, and offers full traceability because every request can be attributed to a specific admin user. Symfony's security component with an internal user provider and `ROLE_BACKOFFICE` is sufficient and does not introduce unnecessary overhead.

A public API for external developers needs a different approach. API keys with scoped permissions are the minimum: every external developer gets a key that has specific scopes (`orders:read`, `products:write`) and can be revoked at any time without deleting the user account. For APIs that act on behalf of end users, OAuth2 with the Authorization Code flow is the right choice. It gives end users control over which applications can access their data and matches the expected standard for modern public APIs. Both models coexist, but in a public API there is no reason to offer session authentication.

4. Versioning: When It Is Necessary, When It Is Counterproductive

Backoffice APIs rarely need a formal versioning strategy. If all clients are under your own control and can be updated at the same time, a `/api/backoffice/` URL structure without a version number is simpler to maintain. Breaking changes can be coordinated, and carrying multiple API versions costs maintenance effort without proportional benefit. The one exception: if several internal systems with independent deployment cycles consume the same backoffice API, at least a rough versioning strategy is needed.

Public APIs, on the other hand, need versioning as a promise of stability. `/api/v1/` in the URL is the most widely used pattern and has the advantage of being immediately visible to developers. The alternative, versioning via the Accept header (`Accept: application/vnd.mironsoft.v2+json`), is semantically cleaner but harder to test and document. Important: a new API version does not mean the old one is switched off immediately. A deprecation period of at least 12 months with clear deprecation warnings in the response header (`Sunset: Sat, 31 Dec 2027 23:59:59 GMT`) is good API citizenship.


<?php
// Symfony API version detection via URL prefix
// config/routes/api_public.yaml:
// api_v1:
//   resource: '../src/Controller/Api/V1/'
//   prefix: /api/v1
//
// api_v2:
//   resource: '../src/Controller/Api/V2/'
//   prefix: /api/v2

// EventSubscriber: inject deprecation headers for v1 responses
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::RESPONSE)]
final class ApiDeprecationSubscriber
{
    public function __invoke(ResponseEvent $event): void
    {
        $path = $event->getRequest()->getPathInfo();

        if (str_starts_with($path, '/api/v1/')) {
            $response = $event->getResponse();
            $response->headers->set('Deprecation', 'true');
            $response->headers->set('Sunset', 'Sat, 31 Dec 2027 23:59:59 GMT');
            $response->headers->set('Link', '</api/v2/>; rel="successor-version"');
        }
    }
}

5. Rate Limiting and Throttling

Rate limiting is existential for public APIs and optional for backoffice APIs. A public API has to protect itself against both accidental and deliberate overload. An external developer who accidentally builds an infinite loop can, without rate limiting, affect the entire infrastructure. The usual model: limit requests per API key and time window, communicate the limits in response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`), and respond with HTTP 429 and a `Retry-After` header when the limit is exceeded. Symfony's RateLimiter component implements token bucket and sliding window algorithms without external dependencies.

Backoffice APIs benefit from rate limiting as protection against bugs in internal scripts, but the limits can be significantly more generous. An internal batch operation that imports 10,000 records must not be blocked by a rate limit that is too tight. The more sensible approach for backoffice APIs is connection pooling at the database level and circuit breakers for dependent external services, not artificial limits at the HTTP level. Asking "who calls this, and what load patterns are expected?" leads to more sensible limits than a single rule applied to every endpoint.

6. Error Models and Response Granularity

The error model is another area with deep differences. A backoffice API can return detailed technical information on error: stack trace, SQL query, internal exception message. That is valuable for debugging during development and acceptable for internal teams because there is no risk of the information becoming public. A public API must never return stack traces, SQL errors, or internal system paths in error responses; that is both a security risk and a poor developer experience.

For public APIs, RFC 9457 (Problem Details for HTTP APIs) is recommended: a standardized JSON object with `type`, `title`, `status`, `detail`, and optional extensions. External developers know this format, can parse it automatically, and receive consistent error descriptions without internal system details. Validation errors across multiple fields should be returned in full (an `errors` array), not just the first error encountered. A developer who filled in four fields incorrectly and needs four API calls to discover all the errors will not have a positive developer experience.

7. Pagination and Filtering Strategy

Pagination is necessary in both API types, but with different strategies. Backoffice APIs for admin panels can use offset-based pagination (`?page=2&per_page=50`) because the dataset is controlled and the use case typically has no high-volume streaming requirements. Cursor-based pagination performs better on large datasets, but it requires a different client implementation and is often overkill for simple admin interfaces.

Public APIs, used by developers for all sorts of use cases, should offer cursor-based pagination. If someone iterates through a product list with 500,000 entries, offset-based pagination is not just slow, it also produces inconsistent results during concurrent write operations (a record can be skipped or appear twice). Cursor-based pagination delivers deterministically complete results. The cursor information belongs in a `meta` block of the response body, not in HTTP headers, because it can be a complex structure.

Aspect Backoffice API Public API Rationale
Authentication Session / internal JWT API key + OAuth2 Controlled vs. unknown client
Versioning Optional / coordinated URL prefix /v1/ mandatory Own vs. third-party deployment cycles
Rate Limiting Generous / circuit breaker Strict per API key Known vs. unknown load patterns
Error Details Stack trace allowed RFC 9457 Problem Details Useful internally vs. safe externally
Pagination Offset-based is sufficient Cursor-based preferred Controllable vs. arbitrary volume

8. Stability and Breaking Changes

Stability may be the most fundamental difference in API design. A backoffice API can introduce breaking changes as long as all clients are updated in a coordinated way. That makes iterative development much easier: a new data model, a renamed field, a changed status format, these are refactorings for an internal API, not API design problems. The team has full control over the migration window and can, if necessary, run a big bang deploy.

Public APIs, in contrast, carry the burden of backward compatibility as a primary design requirement. A field that has once appeared in a response must not simply be removed. Renaming a field requires a deprecation process in which the old field is still returned alongside the new name for at least 12 months. New optional fields in responses are backward compatible. New required fields in requests are not. This asymmetry requires API designers to think in terms of stability categories from the start and to deliberately mark fields as stable or experimental.


<?php
// Symfony EventSubscriber: log deprecated field usage from public API clients
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Psr\Log\LoggerInterface;

#[AsEventListener]
final class DeprecatedFieldUsageListener
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        if (!str_starts_with($request->getPathInfo(), '/api/v1/')) {
            return;
        }

        // Track if client sends old field name
        $body = json_decode($request->getContent(), true) ?? [];

        if (isset($body['customer_name'])) {
            // 'customer_name' was renamed to 'full_name' in v1.3
            $this->logger->warning('Deprecated field used: customer_name', [
                'api_key'  => $request->headers->get('X-Api-Key', 'unknown'),
                'endpoint' => $request->getPathInfo(),
            ]);
        }
    }
}

Mironsoft

REST API design, Symfony backend development, and API strategy

REST APIs designed for your specific use case?

We design and implement backoffice and public APIs with Symfony that get the priorities right from the start: stability, security, and developer experience matched to your specific client requirements.

API Audit

Analyze existing APIs, identify security gaps and design flaws

API Design

Design new APIs from the ground up, with a clear backoffice/public split

Migration

Split existing APIs into v1/v2 with a deprecation strategy and client communication

10. Summary

The central takeaway of this article: backoffice APIs and public APIs share HTTP as transport, but their design requirements are fundamentally different. A backoffice API can be rich in internal detail, can forgo formal versioning, and benefits from verbose error messages for debugging. A public API needs strict data separation, formal versioning as a promise of stability, standardized error models following RFC 9457, and rate limiting to protect everyone involved.

The organizational consequence of this design decision follows directly: whoever builds the same API for internal and external consumers optimizes for neither use case. Splitting into separate endpoints, or even separate services, is more effort up front, but it pays off through lower ongoing maintenance complexity. A backoffice API that cannot return stack traces because of public API requirements is worse for internal operations. A public API that returns internal fields because of backoffice requirements is a security risk.

Backoffice vs. Public API: The Essentials at a Glance

Backoffice API

Internal clients, coordinated breaking changes, verbose error messages, generous rate limiting. No versioning requirement, no OAuth2 overhead.

Public API

URL versioning (/v1/), API keys + OAuth2, RFC 9457 error model, strict rate limiting, cursor pagination, 12-month deprecation period.

Data Separation

Internal fields (purchase price, warehouse location, notes) never in public API responses; separate serialization layers enforce this.

Stability

Public API: additive changes are safe, breaking changes require a new version plus a Sunset header. Backoffice: a coordinated big bang is possible.

11. FAQ: REST APIs for Backoffice vs. Public

1Do I really have to build two separate APIs?
Not necessarily two deployments, but separate routing prefixes with separate controllers and serialization layers. That enforces data separation without duplicate infrastructure.
2Why no internal fields in public APIs?
Data protection and unwanted dependencies. External developers build on returned fields, and a later removal of an internal field then becomes a breaking change.
3Best versioning strategy for public APIs?
URL prefix /api/v1/ is the most widely used. Sunset header plus 12 months of deprecation time for breaking changes.
4Implementing rate limiting in Symfony?
symfony/rate-limiter with token bucket or sliding window. Limits per API key, set X-RateLimit-* headers, return HTTP 429 with Retry-After when exceeded.
5What is RFC 9457 Problem Details?
IETF standard for machine-readable error responses. JSON with type, title, status, detail, and optional extensions for field errors or correlation IDs.
6Cursor vs. offset pagination?
Cursor-based for large data with concurrent write operations. Offset pagination can skip records or return them twice when changes happen concurrently.
7Communicating breaking changes?
Sunset header, Deprecation header (RFC 8594), email to registered developers, changelog, and migration guide. All channels, at least 12 months of lead time.
8Stack traces allowed in backoffice API?
Yes in development, in production only if strictly limited to internal networks. Never let them leak externally through misconfiguration.
9Securing a backoffice API?
Network level: only from internal networks/VPN. Application level: ROLE_BACKOFFICE, JWT with short expiry, audit logging. No public DNS entry.
10OAuth2 for backoffice too?
Only if external systems need access. For purely internal systems, an internal JWT is simpler to implement and to maintain.