Software & practice

PHP routing: clear addresses and correct HTTP responses

A clear contract between information, request and response: fixed pages, controlled moves, language editions and actual HTTP checks.

A red route leads from /angebot through status 308 to /leistungen in a conceptual URL mapping model; other paths remain unlit.

A useful address makes a lasting promise: people will find a particular piece of information or a clearly defined function at this location. Routing decides which part of an application fulfils that promise. A readable slug alone cannot do the job. The request path, HTTP method, available content and response status must agree. This guide develops a small PHP implementation and explains the decisions that deserve explicit attention when a business website grows.

What routing actually decides

Routing assigns an incoming request to the part of the application responsible for handling it. For a service page, that handler might retrieve editorial content and produce HTML. For an API, it might return structured data. For an unknown path, it should produce an understandable error. The router itself does not need to manage paragraphs or construct database queries. Its essential responsibility is an unambiguous assignment.

Separate three questions: which address was requested, which action does the method represent and does the requested content actually exist with permission to display it? A matching route initially answers only the first question or the first two. An article pattern can match perfectly even though the supplied slug identifies no published article. Keeping these decisions separate prevents arbitrary strings from receiving apparently successful responses.

For a small business presence, an explicit list is often sufficient. Introduce a general pattern when several resources genuinely share behaviour. Three fixed information pages do not need a universal expression that automatically turns arbitrary request paths into PHP filenames. Start with the smallest understandable contract and expand it when a concrete content type requires that expansion.

Keep paths, queries and fragments distinct

The components of a URI serve different purposes. In /leistungen?bereich=pflege#ablauf, the path identifies the service page. The query supplies an additional selection. The fragment identifies a location within a representation and is not sent as a fragment to the HTTP server during ordinary browser navigation. RFC 3986 documents these structural distinctions.

Decide whether each query parameter changes the information being requested. A search term belongs to a search function. A campaign parameter may only describe where a visitor came from. A page number selects a different group of results. Treating all parameters alike either creates needless variants or discards useful state. The information pages in our example deliberately ignore the query because they implement none of those additional functions.

The PHP manual explicitly distinguishes parse_url() from full URL validation. Parsing and deciding what is acceptable remain separate operations. Our narrow learning router handles the ordinary path-based request target and first separates it at the question mark. This limited choice is not a replacement for a general proxy implementation or a comprehensive URL library.

Develop addresses from visitor tasks

Start with a small address inventory alongside the content plan. For each entry, record the visitor task, page type, preferred address and responsible content owner. A repair service might deserve its own page because people specifically need that service. The current employee responsible for it is rarely a useful public path component. An internal staffing change should not automatically force a change to every relevant public address.

Choose understandable words that reliably distinguish the content. A slug does not need to reproduce an entire headline. Five similar variants of one service remain confusing even when each contains another search phrase. Describe the substantive difference between the pages first. Only then decide whether separate public locations are justified. This conversation also exposes pages that have different names but no distinct purpose.

Google's URL structure guidance addresses descriptive words, hyphens as word separators and unnecessary combinations of parameters. It does not guarantee a search position. Our practical implementation decision is to document a naming convention that editors and developers can both apply when creating the next service page.

Write down a small routing contract

RequestExample decisionReason
GET /leistungen200 with contentExisting preferred information page
HEAD /leistungen200 without a response bodyRetrieve metadata for the same resource
GET /angebot308 to /leistungenExplicitly maintained previous address
GET /kontakt/308 to /kontaktConsistent spelling for a known page
POST /kontakt405 with AllowThis example does not accept messages
GET /unknown404No corresponding public content

This table describes our application contract. It can be reviewed before anyone writes the router. The contact page illustrates a particularly important distinction: the page name does not require it to accept every HTTP method. A future form processor needs a separate explicit contract covering validation and what actually happens when a message is submitted. Displaying contact information alone creates no such processing capability.

Choosing addresses without a trailing slash is not a universal requirement either. Both conventions can work. What matters is a preferred form, consistent internal links and controlled handling of known alternatives. The implementation should express that choice rather than leave it to whichever server rule happens to run first.

The complete small PHP router

The code separates a testable decision function from response emission. It intentionally returns plain text instead of pretending to be a complete content management system. A production application would render its approved templates after making the routing decision. Fixed internal destinations prevent an untrusted return address from being copied directly into a redirect header. The German response labels are the same in both runnable examples.

<?php
declare(strict_types=1);

function route(string $method, string $target): array
{
    // Deliberately small ASCII policy for this public-page example.
    $path = explode('?', $target, 2)[0];
    if (!preg_match('~^/(?:[a-zA-Z0-9/-]*)$~D', $path)
        || str_contains($path, '//')) {
        return [400, [], 'Ungültiger Anfragepfad.'];
    }

    $pages = [
        '/' => 'Startseite',
        '/leistungen' => 'Leistungen',
        '/kontakt' => 'Kontaktinformationen',
    ];
    $aliases = ['/angebot' => '/leistungen'];
    $candidate = $path === '/' ? '/' : rtrim($path, '/');
    $destination = $aliases[$candidate] ?? $candidate;
    if (!array_key_exists($destination, $pages)) {
        return [404, [], 'Diese Seite wurde nicht gefunden.'];
    }
    if (!in_array($method, ['GET', 'HEAD'], true)) {
        return [405, ['Allow' => 'GET, HEAD'], 'Methode nicht erlaubt.'];
    }
    if ($path !== $destination) {
        // Fixed internal destinations; no user-supplied return URL.
        return [308, ['Location' => $destination], 'Adresse dauerhaft geändert.'];
    }
    return [200, [], $pages[$destination]];
}

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$target = $_SERVER['REQUEST_URI'] ?? '/';
[$status, $headers, $body] = route($method, $target);
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
foreach ($headers as $name => $value) {
    header($name . ': ' . $value);
}
if ($method !== 'HEAD') {
    echo $body;
}

The response declares its character encoding. The learning implementation also sends no-store so that persistent browser caching does not obscure the behaviour being inspected. This is not blanket cache advice for public information pages. Production caching requires a separate policy for updates, response variants and intermediaries. Once that policy exists, test it alongside route behaviour instead of assuming every successful response has the same lifetime.

The example router checks the path, known destination, method and canonical address in that order. Results include 400,404,405, redirect 308 and successful 200.
The exact sequence of this small router checks an unknown destination before its method. POST /unbekannt and POST /angebot therefore produce different statuses. HEAD responses have no body, including errors and redirects.

Why the checks use this order

The function first checks the path form allowed by this particular example. Percent signs, dots, backslashes and repeated slashes are rejected. That deliberately limits the teaching scope. An international editorial system needs a different policy covering Unicode, percent encoding and agreement with the server in front of the application. Repeated decoding should not be added casually as an attempt to repair every unexpected input.

Next, the router determines a possible destination only for the known pages. Unknown addresses remain unknown. It then checks whether the request uses GET or HEAD. Only after that does it consider a redirect. Consequently, a POST to the old offer address does not accidentally get redirected into some other processing workflow. This application does not accept state-changing requests at those locations at all.

An API router may require a different contract. The important point is that the order follows intended behaviour and is verified with actual requests. A vague comment such as “clean up URLs” does not explain the consequences. When reviewing a change, ask which previously accepted request will now receive a different status, destination or body.

Redirect without introducing new problems

A redirect needs a genuinely appropriate destination. The old offer page in this example points to the new services page because they are intended to answer the same underlying visitor task. Sending a removed vacancy to the home page is a different editorial decision: visitors may find no answer there. Maintain moves as content relationships rather than using a broad technical rule to disguise everything that no longer exists.

Google's redirect documentation distinguishes permanent from temporary signals. Align the nature of the move with the technical response. A temporary campaign change should not become a permanent move simply because that option appears first in a hosting interface. Keep the reason for the change with the mapping so another developer can understand it later.

Our old address and the known trailing-slash variant lead directly to the preferred destination. In a real migration, inspect the entire sequence including HTTPS, hostnames and language prefixes. Several individually reasonable rules can combine into an unnecessary detour or a loop. Update internal links to the final destination as well, so the website does not keep relying on its own historical redirects.

Respect methods and response status

RFC 9110 defines HTTP method and status semantics. Four distinctions matter in this implementation: successful retrieval receives 200, unknown content receives 404, an unsupported method for a known resource receives 405 with Allow and the selected permanent redirect uses 308 while preserving the method.

HEAD has no response body. Here, the decision uses the same content entry as GET and the emission step suppresses the body. Larger applications must preserve that relationship when automatically generated representation headers are involved. A successful HEAD check also does not prove that a full GET response renders the correct page. Exercise both paths and inspect the actual content where relevant.

Do not make GET place an order or delete an account. A link can be prefetched, repeated or followed by a crawler. State-changing functions require suitable methods and additional safeguards. The route check shown here is neither authentication nor authorization and does not constitute a form security design. Those controls belong to the actual operation and must remain effective even when the route is known.

Error pages are part of content architecture

A helpful error page briefly explains that the requested page could not be found. It can offer navigation and an existing search function while preserving the appropriate error status. Attractive presentation with 200 does not turn missing information into a successful resource. Google's HTTP status documentation explains how crawlers handle these responses and related soft-error situations.

Avoid automatically listing supposedly related pages when their relevance has not been checked. For a compact business website, a few primary service areas and a clear contact route may be more useful. The error response should not disclose private filesystem paths, configuration values or detailed server exceptions. Public guidance and internal diagnostics serve different audiences and should not be mixed.

After a relaunch, review clusters of unknown paths. One typing mistake rarely justifies a new route. Many requests for the same previous link may reveal a forgotten redirect or an incorrect internal reference that is still being generated. The underlying repair may belong in the link inventory rather than in an ever-growing collection of special router exceptions.

Separate dynamic content from its address

In a journal, the public address identifies a page while the underlying record also has a stable internal ID. Changing a headline should not automatically replace that record or break all its relationships. If the slug really needs to change, retain the previous identifier as an explicit alias. Simply overwriting the only recorded value destroys the information required to create a precise redirect later.

A dynamic route should report success only after checking the relevant content record. Publication status and language matter alongside existence. A scheduled article need not produce the same response for an ordinary public request and an authorized preview. That decision belongs in the responsible application layer and cannot safely be inferred from a plausible-looking slug alone. Define the public behaviour before exposing previews or scheduled content.

Also settle conflicts between fixed and dynamic paths. An article named “search” must not silently replace the actual search function. Reserved terms and distinct route groups are easier to explain than accidental winners in a long list of overlapping patterns. When a new content type is introduced, review the existing address space before accepting its first slug.

Routing is not filesystem access

Avoid attaching user input directly to include or to a filesystem path. A requested public term is not a trustworthy template filename. OWASP describes path traversal as manipulated path references reaching outside intended directories. A sound design begins by avoiding this unnecessary connection between public identifiers and filesystem locations.

Our fixed list contains only response content. In a larger application, an approved route can select a known controller or fixed template identifier. Uploaded files require their own storage and delivery policy. A download endpoint must explicitly check authorization and the permitted file even when its URL pattern looks perfectly tidy. Readability does not establish permission to retrieve a resource.

rawurldecode() transforms encoded text. It does not decide whether the resulting value is permitted in a route or a file operation. Specify which layer decodes and which representation is validated. A proxy and an application interpreting the same input differently can create errors that a simple successful-path test will never reveal.

Coordinate languages and preferred addresses

An English edition is not automatically a duplicate German page. It contains its own text and may use a translated slug. Connect language editions through a stable internal relationship. Where possible, a language switch should lead to the corresponding content rather than always returning visitors to a language home page. Missing translations need an explicit editorial policy instead of an invented destination that happens to look plausible.

For genuinely equivalent variants, Google explains several ways to identify a preferred URL. Coordinate internal links, the sitemap and canonical references. A canonical declaration is not a substitute for a routing decision and does not restrict access. Pages with different substantive content should not be collapsed onto one preferred address simply because maintaining the distinctions takes work.

Return to the visitor task when evaluating query variants. A filtered result list can represent useful shareable state. A source-tracking parameter may not justify a distinct indexable page. Establish that distinction before generating large combinations of filter links, then verify that the user-facing controls and resulting responses implement the intended choice.

Test the example with actual HTTP requests

Save the code as router.php and run php -S 127.0.0.1:8099 router.php from its directory. The PHP built-in server documentation identifies this as a development tool. Public hosting requires a suitable server configuration and a separate check of its rewrite behaviour. Do not infer production behaviour merely from a successful development-server request.

curl -i http://127.0.0.1:8099/leistungen
curl -I http://127.0.0.1:8099/leistungen
curl -i http://127.0.0.1:8099/angebot
curl -i -X POST http://127.0.0.1:8099/kontakt
curl -i http://127.0.0.1:8099/unbekannt

During preparation of this article, the embedded PHP from both language editions was executed as an actual PHP file. The checks inspect real HTTP responses including Location, Allow and the empty HEAD body. They also cover unknown addresses, query handling and deliberately rejected path forms. These are functional checks of this small example rather than security certification for a production deployment.

A migration handover matrix

AreaConcrete questionEvidence
Existing addressesWhere does each important old address lead?Reviewed mapping with a content owner
ResponseDo status and visible information agree?HTTP request and page inspection
VariantsAre there host, slash or language loops?Complete redirect sequence
PublicationDo scheduled articles remain hidden publicly?Separate public and authorized checks
OperationsWho repairs new errors in the link inventory?Named responsibility and recurring review

Do not test only the new navigation. Old bookmarks, printed QR codes, search results and external references enter the system elsewhere. Listing the important entry points makes these dependencies visible. Preserve the mapping after launch so future changes do not break the addresses that the migration has just rescued. Record exceptions with their purpose instead of relying on one person's memory of a previous structure.

Pre-publication checklist

  • Every public address has an understandable content purpose or an explicitly defined function.
  • Fixed and dynamic routes do not overlap unintentionally.
  • Allowed methods are specified for each operation.
  • Unknown content receives an appropriate error status.
  • Important old addresses have reviewed, relevant destinations.
  • Redirects reach the preferred address without a loop.
  • Query parameters have documented meanings.
  • No user input directly becomes a template or filesystem path.
  • Internal links, canonical references, language relationships and the sitemap agree.
  • The actual hosting environment passes the essential HTTP checks used for the application.

Frequently asked routing questions

Should every address be as short as possible? Clarity and stability matter more than removing the final few characters. A short ambiguous label can be less helpful than a slightly longer precise phrase. Remove accidental technical detail and overlapping meanings first, then assess whether further shortening actually helps a person recognize the destination.

Should every unknown address lead to the home page? The home page cannot replace arbitrary missing information. Use a targeted redirect for a real move to a relevant destination. For unknown locations, a helpful error page with the appropriate status provides a more reliable basis for visitors and automated clients.

Is a custom router always better than a framework? This small function makes decisions visible. An existing framework often already provides route resolution and method handling. Use those established mechanisms when your project is built around them. Transfer the content rules and meaningful request tests instead of introducing a second competing routing owner.

When is the planning sufficient? When editorial staff and developers expect the same address, public visibility and future move behaviour for a new piece of content. One working example is the beginning. A maintained agreement between information, request and response turns that example into a dependable system.

Let’s move forward

What would you like to change?

An idea, a specific challenge or your next step with AI. We’re listening.

Let’s talk