Build a Custom API with PHP
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. In 2026, that counter should speak ordinary HTTP, return JSON, validate input before touching the database, use prepared statements, and publish a contract that another client can follow without reading the 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. That sounds basic, but it is the piece many home-grown APIs blur. 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} The API contract is bigger than the PHP file. Like the order form at the service counter, it tells every caller what can be requested and which details belong with each request.
OpenAPI describes that contract in a language-agnostic way, so humans and tools can understand an HTTP API without reading the server code. You do not need a huge spec on day one, but you do need enough discipline that the spec could be written honestly.
Return JSON Deliberately
Use JSON for the response body and set the content type.
<?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. That is a small choice, but APIs are made of small choices that become contracts.
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, true, 512, JSON_THROW_ON_ERROR);
if (! is_array($decoded) || array_is_list($decoded)) {
throw new InvalidArgumentException("JSON body must be an object.");
}
return $decoded;
} That helper stays deliberately plain for a reason. The important detail is where the rule lives: every handler reads JSON the same way, so invalid bodies produce the same error shape across the API. array_is_list() requires PHP 8.1 or newer and rejects JSON lists after associative decoding. If one endpoint accepts missing bodies, one endpoint accepts arrays, and one endpoint silently treats bad JSON as empty input, the contract is already leaking.
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") {
$input = readJsonBody();
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);
}
jsonResponse([
"error" => "Not found.",
], 404); In this sketch, db() returns the application's PDO instance, while currentUserId() comes from the authenticated session layer.
Older PHP tutorials of that era 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 is the underlying request shape, not a framework recommendation. 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, treat it as a historical clue. The traffic was asking for an API pattern, not for that SQL pattern.
Status Codes
Use status codes consistently across the whole API:
200for a successful read or update.201when a resource is created.204when a delete succeeds with no body.400for malformed input.401when client authentication is missing or invalid.403when the user is authenticated but not allowed.404when the resource does not exist or should not be revealed.409for a state conflict.422for 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. It is the service counter's standard receipt: 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:
- Require HTTPS for every token-bearing request.
- Store token hashes rather than plain tokens.
- Scope tokens to the specific actions they need.
- Rate-limit login, write, and export endpoints.
- Log failures without logging secrets or request bodies.
This is where PHP security fundamentals stops being a checklist and becomes the API's floor.
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. Most API bugs in small apps happen at the second boundary, where the code finds a valid user and then forgets to constrain the query by that user's allowed records.
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, the lesson to keep is the boundary, not 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
Symptom: a create request succeeds but returns 200, or a missing record returns a successful JSON body. Cause: each handler chose its response independently. Fix: write the method, path, success status, and error statuses into the contract, then test them together.
Authorization Stops at Authentication
Symptom: a valid user can request another user's note by changing the ID. Cause: the token check identifies the caller, but the query does not constrain the row by that caller's permissions. Fix: include the ownership or authorization condition in every protected read and write.
A Retry Creates the Resource Twice
Symptom: one client action creates duplicate rows after a timeout. Cause: the client retries a request after the server committed the first write but before the response arrived. Fix: give retryable create operations an idempotency key or another unique application constraint.
Frequently Asked Questions
Should a DELETE request be idempotent?
Yes. Deleting the same resource twice should leave the same end state, typically a success the first time and a not-found the second, rather than failing differently on repeat calls. That is what makes a retry safe.
Should the version live in the path, a header, or a query string?
All three exist in real APIs. A path segment is the most visible and the easiest to cache. A header keeps URLs stable across versions but is invisible in logs and easy to forget when testing by hand.
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?
Not unless a specific client genuinely requires XML. Supporting a second request format doubles the parsing, validation, and error surface for a benefit most new APIs never collect.
Should an API expose sequential database IDs in its URLs?
It can, since authorization protects the row regardless of ID shape, but a sequential ID lets a caller guess how many records exist and enumerate others. An opaque identifier such as a UUID avoids that leak without changing how authorization works.
Does a REST API need to implement every HTTP method, like PUT?
No. A resource only needs the methods its actual 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()?
Without it, a failed encode returns false silently, which a response function can accidentally send as valid-looking output. With the flag, PHP throws a JsonException instead, so an encoding failure surfaces immediately rather than reaching the client as malformed JSON.
Self-Check
Predict the output from this allowlist fallback:
<?php
$allowedSorts = ["created" => "created_at"];
$requestedSort = "unknown";
echo $allowedSorts[$requestedSort] ?? "created_at"; - Predict the output: Which column name does the snippet print?
- Multiple choice: Can a PDO parameter replace a table name, a complete email value, or the keyword
DESC? - Multiple choice: Which status fits a created resource:
200,201, or204? - Multiple choice: What does authentication establish: caller identity, permission for every row, or both automatically?
- Predict the output: Which column name prints if
$requestedSortchanges to"created"?
Answers
created_at. The unknown key misses the allowlist, so the null-coalescing fallback supplies the approved default.- A complete email value. Parameters represent data values, while identifiers and SQL keywords must come from application allowlists.
201. It tells the client that the request created a resource.- Caller identity. Authorization still has to check whether that caller may act on the requested resource.
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]
Overview of HTTP(developer.mozilla.org)
-
[2]
OpenAPI Specification(spec.openapis.org)
-
[3]
SOAP(php.net)
-
[4]
SoapClient::__construct(php.net)
-
[5]
XML-RPC Specification(xmlrpc.com)
-
[6]
XML-RPC(php.net)
-
[7]
JSON Functions(php.net)
-
[8]
json_encode(php.net)
-
[9]
json_decode(php.net)
-
[10]
http_response_code(php.net)
-
[11]
filter_input(php.net)
-
[12]
Never(php.net)
-
[13]
array_is_list(php.net)
Read Next
Use PDO prepared statements to connect PHP to MySQL safely and keep SQL input handling clear.
Compare Laravel, Symfony, CakePHP, Slim, and Fat-Free Framework for modern PHP projects.
Modernize the old CodeWalkers SMS-over-HTTP pattern around provider APIs, consent, status callbacks, retries, and safe logging.