Build a Custom API with PHP

Published Updated

A custom API lets an application expose its data without giving every client direct database access. This is the capstone step of the PHP web-app path: the app already has a database-backed screen, comments, and search, and now it exposes a small JSON contract that another client can call.

Think of the API as a service counter. The route names the service, the request carries the order, and the response is the receipt. Define each route, its accepted input, and its response before connecting it to the database. Use HTTP status codes, JSON, input validation, and prepared statements so another client can use the API without reading its PHP source.

Guide Path

Start with dynamic PHP and MySQL pages, then add news and comments and search. For the authentication layer itself, read PHP authentication and sessions, and keep PHP security fundamentals close for the broader boundary rules.

Start with the Contract

HTTP is a protocol built around requests and responses, and many home-grown APIs blur that. The method, path, status code, headers, and body all carry meaning.

A small notes API might start with this shape:

GET    /api/notes
POST   /api/notes
GET    /api/notes/{id}
PATCH  /api/notes/{id}
DELETE /api/notes/{id}

Document each route’s method, required fields, response and possible errors so callers know how to use it.

OpenAPI describes that contract in a language-agnostic way, so humans and tools can understand an HTTP API without reading the server code. Start with the routes clients actually use and keep the specification in step with the code.

How Does a PHP API Read and Return JSON?

A PHP API reads JSON with json_decode() and returns it with json_encode(). Set the response content type to application/json and handle decoding errors before querying the database.

<?php

function jsonResponse(array $data, int $status = 200): never
{
    http_response_code($status);
    header("Content-Type: application/json; charset=UTF-8");
    echo json_encode($data, JSON_THROW_ON_ERROR);
    exit;
}

JSON_THROW_ON_ERROR keeps encoding failures from becoming quiet partial responses.

The never return type is available in PHP 8.1 and newer. It fits this helper because every path ends the request with exit. On PHP 8.0, omit the return type while keeping the same control flow.

The matching input helper deserves the same care. json_decode() can return null for more than one reason unless you make failures explicit. In PHP 8 code, prefer the throwing mode, cap the depth, and handle malformed bodies before the request reaches a database function.

<?php

function readJsonBody(): array
{
    $raw = file_get_contents("php://input");

    if ($raw === false || trim($raw) === "") {
        throw new InvalidArgumentException("Request body is required.");
    }

    $decoded = json_decode($raw, false, 512, JSON_THROW_ON_ERROR);

    if (! ($decoded instanceof stdClass)) {
        throw new InvalidArgumentException("JSON body must be an object.");
    }

    return get_object_vars($decoded);
}

Decoding to an object first distinguishes a JSON object from a list, including an empty object. The helper then returns its properties as an array. Catch malformed JSON and missing bodies in the route and return a 400 response.

Route the Request Plainly

A front controller can handle a small API without pretending to be a full framework.

<?php

require __DIR__ . "/../src/db.php";
require __DIR__ . "/../src/notes.php";
require __DIR__ . "/../src/http.php";

$path = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$method = $_SERVER["REQUEST_METHOD"];

if ($path === "/api/notes" && $method === "GET") {
    jsonResponse([
        "data" => listNotes(db(), currentUserId()),
    ]);
}

if ($path === "/api/notes" && $method === "POST") {
    try {
        $input = readJsonBody();
    } catch (JsonException | InvalidArgumentException $error) {
        jsonResponse(["error" => "Send a valid JSON object."], 400);
    }

    if (
        ! is_string($input["title"] ?? null)
        || ! is_string($input["body"] ?? null)
    ) {
        jsonResponse([
            "error" => "Title and body must be strings.",
        ], 422);
    }

    $title = trim($input["title"]);
    $body = trim($input["body"]);

    if ($title === "" || $body === "") {
        jsonResponse([
            "error" => "Title and body are required.",
        ], 422);
    }

    $note = createNote(db(), currentUserId(), $title, $body);

    jsonResponse([
        "data" => $note,
    ], 201);
}

if ($path === "/api/notes") {
    header("Allow: GET, POST");
    jsonResponse(["error" => "Method not allowed."], 405);
}

jsonResponse([
    "error" => "Not found.",
], 404);

This routing example is not a standalone application. Supply the imported files, database connection, authentication helper and createNote() function. The currentUserId() helper must reject unauthenticated requests; createNote() must save the note for that user. The listing function appears below.

Older PHP tutorials often used one file that dispatched on ?action=get_notes. That could serve a one-client intranet tool, but it became difficult to maintain when another caller needed different data. This sketch shows the underlying request shape. Laravel, Symfony, Slim, and Fat-Free Framework can all give you better routing and request objects once the app grows.

For a capstone tutorial, keep the route table visible for the first draft. Writing it by hand once shows where the contract begins and where the PHP plumbing ends. After that, a framework can take over repeated route matching, middleware, request objects, response classes, and controller dispatch.

Validate Before SQL

The API boundary is still a user-input boundary. A JSON body is not safer than a form post just because it looks more structured.

If you are building the same rules for browser forms, read form validation in PHP. The transport changes, but the server still owns the decision.

<?php

function readPositiveId(string $value): int
{
    $id = filter_var($value, FILTER_VALIDATE_INT, [
        "options" => ["min_range" => 1],
    ]);

    if ($id === false) {
        throw new InvalidArgumentException("Invalid ID.");
    }

    return $id;
}

Then keep the database boundary behind prepared statements.

<?php

function listNotes(PDO $pdo, int $userId): array
{
    $statement = $pdo->prepare(
        "SELECT id, title, created_at
         FROM notes
         WHERE user_id = :user_id
         ORDER BY created_at DESC
         LIMIT 50",
    );

    $statement->execute(["user_id" => $userId]);

    return $statement->fetchAll(PDO::FETCH_ASSOC);
}

If an old API tutorial builds SQL strings from request parameters, borrow its routes and drop its SQL.

Status Codes

Use status codes consistently across the whole API:

  • 200 for a successful read or update.
  • 201 when a resource is created.
  • 204 when a delete succeeds with no body.
  • 400 for malformed input.
  • 401 when client authentication is missing or invalid.
  • 403 when the user is authenticated but not allowed.
  • 404 when the resource does not exist or should not be revealed.
  • 409 for a state conflict.
  • 422 for syntactically valid input that fails validation.

The exact split between 400 and 422 matters less than consistency. Document the rule and keep the behavior stable.

Standardize the Error Body

Status codes tell the client what category of thing happened. The JSON body tells the client what to do next.

A practical error envelope can stay this small:

{
  "error": {
    "code": "validation_failed",
    "message": "Title and body are required.",
    "fields": {
      "title": "Title is required."
    }
  }
}

Use that same shape across every handler. A browser can show message to the user, a mobile client can branch on code, and an automated client can inspect fields without scraping a paragraph of prose.

Keep sensitive details out of that response. The client needs to know that a record was not found, a token was rejected, or an input field failed validation. It does not need a SQL error, a stack trace, a filesystem path, a raw provider response, or the name of the internal service that failed.

API Versioning

The first version does not need a ceremony around it, but it does need a place to live. Use a versioned path such as /api/v1/notes once another system will depend on the contract.

Versioning protects clients only when behavior stays predictable. If /v1/notes returns created_at as an ISO string on Monday and a Unix timestamp on Friday, the version did not protect anyone. The version protects clients only when you treat response fields, field names, required inputs, pagination shape, and error codes as part of the public surface.

For a small PHP app, the practical rule is simple enough:

  • Add fields without breaking existing fields.
  • Stop sending a field only in a new version.
  • Change meanings only in a new version.
  • Keep old versions long enough for real clients to move.
  • Document deprecation dates before you remove behavior.

Version rules become concrete when a mobile build, an automation, and a partner script call the same endpoint. A missing field, renamed status, or changed timestamp format can leave those clients running with bad assumptions.

Authentication Is Not Optional

For a private API, require authentication before the handler reaches business logic. A session cookie can work for same-site browser clients. Tokens are a better fit for external clients, mobile apps, and server-to-server calls.

Keep the first version narrow, explicit, and auditable:

  1. Require HTTPS for every token-bearing request.
  2. Store token hashes rather than plain tokens.
  3. Scope tokens to the specific actions they need.
  4. Rate-limit login, write, and export endpoints.
  5. Log failures without logging secrets or request bodies.

Apply the authentication, authorization, and input checks in PHP security fundamentals to every protected endpoint.

The token check should happen before the handler decides what row to read or write. Keep that boundary in middleware or a small guard function:

<?php

function requireApiToken(PDO $pdo): ApiToken
{
    $header = $_SERVER["HTTP_AUTHORIZATION"] ?? "";

    if (! str_starts_with($header, "Bearer ")) {
        jsonResponse(["error" => ["code" => "unauthorized", "message" => "Missing token."]], 401);
    }

    $plainToken = substr($header, 7);
    $token = findTokenByHash($pdo, hash("sha256", $plainToken));

    if ($token === null || $token->isExpired()) {
        jsonResponse(["error" => ["code" => "unauthorized", "message" => "Invalid token."]], 401);
    }

    return $token;
}

Keep authentication separate from authorization in code and prose. Authentication answers who or what is calling. Authorization answers whether that caller can read this note, update this account, or export this report. A valid login does not prevent an authorization bug. Each protected query must still limit access to records that caller is allowed to use.

Pagination and Rate Limits

The first GET /api/notes example used LIMIT 50 because an API without a limit is a database incident waiting for a caller. Use a stable maximum page size, accept a cursor or page token when the result set can grow, and return enough metadata for the client to ask for the next page.

For a simple endpoint, offset pagination is acceptable:

<?php

function listNotesPage(PDO $pdo, int $userId, int $page): array
{
    $page = max(1, $page);
    $perPage = 50;
    $offset = ($page - 1) * $perPage;

    $statement = $pdo->prepare(
        "SELECT id, title, created_at
         FROM notes
         WHERE user_id = :user_id
         ORDER BY created_at DESC, id DESC
         LIMIT :limit OFFSET :offset",
    );

    $statement->bindValue("user_id", $userId, PDO::PARAM_INT);
    $statement->bindValue("limit", $perPage, PDO::PARAM_INT);
    $statement->bindValue("offset", $offset, PDO::PARAM_INT);
    $statement->execute();

    return $statement->fetchAll(PDO::FETCH_ASSOC);
}

Offset pagination gets expensive on deep pages, and it can skip or repeat records while rows are being inserted. When the endpoint matters, move to cursor pagination based on a stable ordered pair such as created_at plus id.

Rate limits belong in the same early bucket. A login endpoint, export endpoint, SMS endpoint, or email-triggering endpoint can cost real money or leak information if the caller can hammer it freely. Start with conservative per-token and per-IP limits, then loosen them when production data proves they are too strict.

Keep CORS and Logs Boring

If a browser on another origin needs to call the API, configure CORS for the exact origins and methods you expect. Do not ship Access-Control-Allow-Origin: * on a private API that also accepts credentials. That mistake looks convenient during local testing and then turns into a policy you have to unwind.

The logging rule needs the same restraint. Log request IDs, endpoint names, status codes, caller IDs, token IDs, and timing. Avoid request bodies, bearer tokens, cookies, raw provider responses, and anything a user typed into a private field. The point is to debug failures without creating a second copy of the data you were supposed to protect.

Legacy XML Behind Adapters

The old CodeWalkers tutorials included SOAP and XML-RPC lessons because remote procedure calls were a practical way to connect small PHP systems before JSON APIs became the default. That history is useful, but it should not become the shape of a new API.

SOAP still appears in payment systems, shipping providers, government systems, and older internal platforms where the WSDL is the contract you inherited. PHP's SOAP extension can read a WSDL and expose remote operations through SoapClient, but the simple method call is a little deceptive. You are sending XML over HTTP to a remote system with its own schema, authentication, timeout behavior, and fault model.

Keep the WSDL URL, credentials, timeout, SOAP version, and trace settings in one place:

<?php

$client = new SoapClient($wsdlUrl, [
    "login" => $_ENV["SOAP_USER"],
    "password" => $_ENV["SOAP_PASSWORD"],
    "soap_version" => SOAP_1_2,
    "exceptions" => true,
    "trace" => false,
    "connection_timeout" => 10,
]);

The old debug move was to turn on tracing and print the last request. Do that only in a controlled local environment. SOAP envelopes can carry credentials, customer identifiers, certificate details, and account data that should never land in production logs.

XML-RPC has a smaller shape: HTTP POST carries an XML request that names a method and passes values, and the XML response returns either values or a fault. The old PEAR XML_RPC pattern wrapped those calls in client, message, and value objects. That pattern was reasonable in its era. In a current PHP app, keep the boundary and drop the package.

If you inherit SOAP or XML-RPC, put every encode, decode, fault-code, timeout, retry, credential, and logging choice behind one adapter. The rest of the app should see application values such as CustomerRecord, BookRecord, or InvoiceStatus, not SOAP envelopes or <methodCall> payloads.

Use SOAP or XML-RPC only when a legacy client or vendor contract still requires it. When you control the new system, build the contract as ordinary HTTP and JSON, document it with OpenAPI, and leave the XML endpoint behind a migration plan.

Common Pitfalls & Debugging

The Route and Status Code Disagree

A create request succeeds but returns 200, or a missing record returns a successful JSON body. Each handler chose its response independently. Write the method, path, success status, and error statuses into the contract, then test them together.

Authorization Stops at Authentication

A valid user can request another user's note by changing the ID. The token check identifies the caller, but the query does not constrain the row by that caller's permissions. Include the ownership or authorization condition in every protected read and write.

A Retry Creates the Resource Twice

One client action creates duplicate rows after a timeout. The client retries a request after the server committed the first write but before the response arrived. Give retryable create operations an idempotency key or another unique application constraint.

Frequently Asked Questions

Should a DELETE request be idempotent?

HTTP DELETE should be idempotent: repeating it should leave the resource in the same deleted state. Responses can differ, such as 204 on the first request and 404 on a later request. The state change, not an identical response, is what matters.

Should an API version go in the path, a header, or a query string?

An API can put its version in a URL path, request header or query string. A path such as /api/v1/notes is easy to see and test. Header versioning keeps URLs stable but requires clients, caches and logging tools to account for that header.

Should a 204 response return JSON?

A 204 No Content response must not return JSON. Use it when the status code fully describes a successful operation, such as a delete, and use a 200 response when the client needs returned data.

Should an API accept both JSON and XML request bodies?

An API should accept XML alongside JSON only when a client needs it. Supporting another format adds parsing, validation and error-handling work.

Should an API expose sequential database IDs in its URLs?

An API can expose sequential database IDs if every request checks permission to access the record. Sequential IDs make nearby IDs easy to guess and can reveal approximate record counts. Random identifiers reduce that predictability but never replace authorization.

Does a REST API need to implement every HTTP method, like PUT?

A REST API does not need to implement every HTTP method. Each resource needs only the methods its operations require. Many APIs never expose PUT at all, using PATCH for partial updates instead, or skip DELETE entirely when a resource is only ever created and read.

What does JSON_THROW_ON_ERROR actually change in json_encode()?

JSON_THROW_ON_ERROR makes PHP’s json_encode() throw a JsonException on encoding failure instead of returning false. Catch the exception in the application’s error handler so the client receives a controlled error response.

Self-Check

Predict the output from this allowlist fallback:

<?php

$allowedSorts = ["created" => "created_at"];
$requestedSort = "unknown";

echo $allowedSorts[$requestedSort] ?? "created_at";
  1. Predict the output: Which column name does the snippet print?
  2. Multiple choice: Can a PDO parameter replace a table name, a complete email value, or the keyword DESC?
  3. Multiple choice: Which status fits a created resource: 200, 201, or 204?
  4. Multiple choice: What does authentication establish: caller identity, permission for every row, or both automatically?
  5. Predict the output: Which column name prints if $requestedSort changes to "created"?

Answers

  1. created_at. The unknown key misses the allowlist, so the null-coalescing fallback supplies the approved default.
  2. A complete email value. Parameters represent data values, while identifiers and SQL keywords must come from application allowlists.
  3. 201. It tells the client that the request created a resource.
  4. Caller identity. Authorization still has to check whether that caller may act on the requested resource.
  5. created_at. The allowed key maps directly to the approved database column.

Next Steps

If the endpoint triggers outbound notifications, read sending SMS with HTTP APIs before wiring a provider into a controller. For the database side of this API, read PHP and MySQL with PDO and SQL transactions and ACID.

If you are tempted to hand-write the network request underneath the API, read using sockets in PHP first. Most APIs should stay at the HTTP contract layer; sockets are for protocol work where the transport itself matters.

Sources

  1. [1]
    Overview of HTTP
    (developer.mozilla.org)
  2. [2]
    OpenAPI Specification
    (spec.openapis.org)
  3. [3]
    SOAP
    (php.net)
  4. [4]
  5. [5]
  6. [6]
    XML-RPC
    (php.net)
  7. [7]
  8. [8]
    json_encode
    (php.net)
  9. [9]
    json_decode
    (php.net)
  10. [10]
  11. [11]
    filter_input
    (php.net)
  12. [12]
    Never
    (php.net)
  13. [13]
    array_is_list
    (php.net)