Exciting news! TCMS official website is live! Offering full-stack software services including enterprise-level custom R&D, App and mini-program development, multi-system integration, AI, blockchain, and embedded development, empowering digital-intelligent transformation across industries. Visit dev.tekin.cn to discuss cooperation!
This article focuses on URL rewriting for PHP's built-in server, offering a lightweight local development solution. The core is to achieve Nginx/Apache-equivalent functionality via custom routing scripts, following "static resources first + rule grouping and sorting", without heavyweight servers.

/bazijp.html to /?ac=bazijp), typically relying on the rewrite directive of Nginx/Apache. However, for local development, PHP's built-in server (php -S) is lighter and more efficient. Equivalent rewriting functionality can be achieved simply through custom routing scripts. This article will provide a directly reusable solution compatible with PHP 5.6+ mainstream versions, covering basic principles, environment configuration, static resource compatibility, and complex rule adaptation (e.g., for ThinkPHP and Laravel projects), combined with the rewriting needs of actual projects.
PHP's built-in server does not natively support rewrite syntax similar to Nginx. It requires intercepting all requests through a routing script (e.g., router.php) and implementing custom forwarding logic. The core process is as follows:
The client initiates a request (e.g., sm.tekin.cn/bazijp.html);
The built-in server forwards the request to the routing script first;
The routing script rewrites the URL according to preset rules (e.g., mapping to sm.tekin.cn/index.php?ac=bazijp);
The rewritten request is forwarded to the project's unified entry (e.g., public/index.php);
If the request is for a static resource (CSS/JS/images), the file is returned directly without entering the rewriting process.
Key Premise: The built-in server has strict requirements for the order of command-line parameters. It must follow php -S [address:port] -t [document root directory] [routing script]. Incorrect parameter order will cause routing failure or failure to load static resources.
Taking the common structure of "separating web root directory from business code" (general for frameworks like ThinkPHP and Laravel) as an example, the directory structure is as follows:
project-root/ # Project root directory
├─ public/ # Web root directory (online access root path)
│ ├─ statics/ # Static resource directory (CSS/JS/images)
│ └─ index.php # Project unified entry file
└─ router.php # URL rewriting routing script
Execute the command in the project root directory, specifying public as the web root directory and router.php as the routing script to ensure consistency with the online environment's directory mapping:
# Basic startup command (local access address: http://127.0.0.1:8000)
php -S 127.0.0.1:8000 -t public router.php
# Enable Xdebug debugging (compatible with PHP 5.6+)
php -dxdebug.remote_enable=1 -dxdebug.remote_autostart=1 -S 127.0.0.1:8000 -t public router.phpTo debug business logic in the IDE, configure launch.json to ensure debugging and rewriting work together. The key is to maintain consistent parameter order with the online environment:
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "PHP Built-in Server + URL Rewriting + Debugging",
            "type": "php",
            "runtimeExecutable": "/opt/local/bin/php56", // Local PHP path (must match project version)
            "request": "launch",
            "runtimeArgs": [
                "-dxdebug.remote_enable=1",
                "-dxdebug.remote_autostart=1",
                "-dxdebug.remote_port=9000", // Consistent with Xdebug port in php.ini
                "-S", "127.0.0.1:8000",
                "-t", "public", // Match online web root directory
                "router.php"    // Routing script must be placed last
            ],
            "cwd": "${workspaceRoot}",
            "serverReadyAction": {
                "pattern": "Development Server \\(http://localhost:([0-9]+)\\) started",
                "uriFormat": "http://localhost:%s",
                "action": "openExternally" // Automatically open local debug address after startup
            }
        }
    ]
}In local development, two common issues arise:
Static resources (e.g., /statics/ffsm/global.css) are intercepted by the routing script, returning 404;
Simple rewriting rules (e.g., /hehun.html → /?ac=hehun) fail to take effect, preventing access to target modules.
Core Logic: Prioritize processing static resources before executing URL rewriting to separate static resource loading from dynamic routing:
<?php
// Define project root directory and web root directory
$projectRoot = __DIR__;
$webRoot = rtrim($projectRoot . '/public', '/') . '/';
// 1. Parse and standardize the request URI
$requestUri = $_SERVER['REQUEST_URI'];
$uriParts = parse_url($requestUri);
$uriPath = isset($uriParts['path']) ? $uriParts['path'] : '/';
$uriPath = preg_replace('#/\.\./#', '/', $uriPath); // Filter directory traversal attacks
$uriPath = rtrim($uriPath, '/'); // Uniformly remove trailing slashes (e.g., `/suanming/scbz/` → `/suanming/scbz`)
$uriPath = $uriPath === '' ? '/' : $uriPath;
// 2. Prioritize processing static resources (return directly if they exist)
$requestedFile = $webRoot . ltrim($uriPath, '/');
if (file_exists($requestedFile) && is_file($requestedFile) && !is_dir($requestedFile)) {
    // Set correct MIME type to avoid browser parsing exceptions
    $mimeType = getMimeType($requestedFile);
    if ($mimeType) {
        header("Content-Type: {$mimeType}");
    }
    readfile($requestedFile);
    exit;
}
// 3. Define basic URL rewriting rules (extendable according to project needs)
$rewriteRules = [
    '#^/index\.html$#' => '/index.php', // Homepage rule
    '#^/bazijp\.html$#' => '/?ac=bazijp', // Bazi precision analysis module rule
    '#^/hehun\.html$#' => '/?ac=hehun', // Marriage compatibility module rule
    '#^/aboutus\.html$#' => '/?ac=aboutus', // About us page rule
    '#^/xyd-([0-9]+)\.html$#' => '/?ac=xyd&id=$1', // Detail page dynamic rule
    '#^/([^/]+)\.html$#' => '/index.php?ac=$1', // Execute last: General .html rule (broadest, avoid overriding previous specific rules)
];
// 4. Apply rewriting rules
$newUri = $uriPath;
foreach ($rewriteRules as $pattern => $target) {
    if (preg_match($pattern, $uriPath)) {
        $newUri = preg_replace($pattern, $target, $uriPath);
        break; // Terminate after matching to avoid rule conflicts
    }
}
// 5. Process query parameters (retain original parameters, new rule parameters override duplicate original parameters)
$originalQuery = isset($uriParts['query']) ? $uriParts['query'] : '';
$newUriParts = parse_url($newUri);
$newPath = isset($newUriParts['path']) ? $newUriParts['path'] : '/';
$newQuery = isset($newUriParts['query']) ? $newUriParts['query'] : '';
$finalQuery = '';
if (!empty($originalQuery) && !empty($newQuery)) {
    parse_str($originalQuery, $originalParams);
    parse_str($newQuery, $newParams);
    $mergedParams = array_merge($originalParams, $newParams);
    $finalQuery = http_build_query($mergedParams);
} elseif (!empty($originalQuery)) {
    $finalQuery = $originalQuery;
} else {
    $finalQuery = $newQuery;
}
// 6. Update server variables and forward to the unified entry
$finalUri = $newPath . ($finalQuery ? "?{$finalQuery}" : '');
$_SERVER['REQUEST_URI'] = $finalUri;
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['QUERY_STRING'] = $finalQuery;
parse_str($finalQuery, $_GET); // Synchronously update GET parameters to adapt to framework parameter retrieval
// 7. Execute the entry file
$indexFile = $webRoot . 'index.php';
if (file_exists($indexFile)) {
    include_once $indexFile;
} else {
    http_response_code(404);
    echo "404 Not Found: public/index.php entry file does not exist";
}
exit;
/**
 * MIME type retrieval compatible with PHP 5.6
 * @param string $file File path
 * @return string|null MIME type
 */
function getMimeType($file) {
    $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
    $mimeMap = [
        'css' => 'text/css', 'js' => 'application/javascript',
        'html' => 'text/html', 'jpg' => 'image/jpeg',
        'png' => 'image/png', 'gif' => 'image/gif', 'ico' => 'image/x-icon'
    ];
    return isset($mimeMap[$extension]) ? $mimeMap[$extension] : null;
}Access http://127.0.0.1:8000/bazijp.html, and var_dump($_GET) should display array('ac' => 'bazijp');
Access http://127.0.0.1:8000/xyd-123.html, and it should display array('ac' => 'xyd', 'id' => '123');
Access http://127.0.0.1:8000/statics/ffsm/global.css, and it should directly return the CSS file content.
In actual projects, it is often necessary to handle a large number of complex rewriting rules (e.g., multi-module routing, dynamic parameter concatenation). For example, a fragment of Nginx rules for a numerology project:
rewrite ^/aboutus.html /index.php?ac=aboutus last;
rewrite ^/xyd-([0-9]+).html /index.php?ac=xyd&id=$1 last;
rewrite ^/(.*).html /index.php?ac=$1 last;
rewrite ^/show-([0-9]+).html /index.php?ct=news&ac=show&id=$1;The core challenge when migrating such rules is to avoid general rules overriding specific ones (e.g., /aboutus.html should not be incorrectly mapped by the general rule /.+\.html).
Core Idea: Group rules by "precision", with exact rules matching first and general rules as a fallback, ensuring the routing logic of each module is consistent with the online environment.
<?php
$projectRoot = __DIR__;
$webRoot = rtrim($projectRoot . '/public', '/') . '/';
// 1. Standardize the request URI
$requestUri = $_SERVER['REQUEST_URI'];
$uriParts = parse_url($requestUri);
$uriPath = isset($uriParts['path']) ? $uriParts['path'] : '/';
$uriPath = preg_replace('#/\.\./#', '/', $uriPath);
$uriPath = rtrim($uriPath, '/');
$uriPath = $uriPath === '' ? '/' : $uriPath;
// 2. Prioritize processing static resources
$requestedFile = $webRoot . ltrim($uriPath, '/');
if (file_exists($requestedFile) && is_file($requestedFile) && !is_dir($requestedFile)) {
    $mimeType = getMimeType($requestedFile);
    if ($mimeType) {
        header("Content-Type: {$mimeType}");
    }
    readfile($requestedFile);
    exit;
}
// 3. Rule Grouping: Exact Rules → General Rules (avoid broad rules overriding narrow ones)
// Note: The following rules need to be modified according to your own project. This is only an example. For more references, see the URL rewriting of https://sm.tekin.cn
// --------------------------
// Group 1: Exact Rules (no dynamic parameters, fixed URLs)
// --------------------------
$exactRules = [
    // Basic pages
    '#^/index\.html$#' => '/index.php',
    '#^/aboutus\.html$#' => '/index.php?ac=aboutus',
    '#^/contactus\.html$#' => '/index.php?ac=contactus',
];
// --------------------------
// Group 2: General Rules (with dynamic parameters, suffix matching)
// --------------------------
$generalRules = [
    // Exact suffix rules with ID
    '#^/xyd-([0-9]+)\.html$#' => '/index.php?ac=xyd&id=$1',
    // Dynamic path for name module
    '#^/xmfx/([^/]+)$#' => '/index.php?ct=xingming&ac=xmfx&name=$1',
    '#^/xqlist-([0-9]+)-([0-9]+)-([0-9]+)-([0-9]+)\.html$#' => '/index.php?ct=xq&xid=$1&sex=$2&hs=$3&page=$4',
    // Execute last: General .html rule (fallback)
    '#^/([^/]+)\.html$#' => '/index.php?ac=$1',
];
// 4. Execute rewriting: Exact rules first, then general rules
$newUri = $uriPath;
$ruleMatched = false;
// Step 1: Match exact rules (core business first)
foreach ($exactRules as $pattern => $target) {
    if (preg_match($pattern, $uriPath)) {
        $newUri = preg_replace($pattern, $target, $uriPath);
        $ruleMatched = true;
        break;
    }
}
// Step 2: If no exact rule is matched, match general rules
if (!$ruleMatched) {
    foreach ($generalRules as $pattern => $target) {
        if (preg_match($pattern, $uriPath)) {
            $newUri = preg_replace($pattern, $target, $uriPath);
            break;
        }
    }
}
// 5. Merge query parameters (same logic as basic version)
$originalQuery = isset($uriParts['query']) ? $uriParts['query'] : '';
$newUriParts = parse_url($newUri);
$newPath = isset($newUriParts['path']) ? $newUriParts['path'] : '/';
$newQuery = isset($newUriParts['query']) ? $newUriParts['query'] : '';
$finalQuery = '';
if (!empty($originalQuery) && !empty($newQuery)) {
    parse_str($originalQuery, $originalParams);
    parse_str($newQuery, $newParams);
    $mergedParams = array_merge($originalParams, $newParams);
    $finalQuery = http_build_query($mergedParams);
} elseif (!empty($originalQuery)) {
    $finalQuery = $originalQuery;
} else {
    $finalQuery = $newQuery;
}
// 6. Forward to the entry file
$finalUri = $newPath . ($finalQuery ? "?{$finalQuery}" : '');
$_SERVER['REQUEST_URI'] = $finalUri;
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['QUERY_STRING'] = $finalQuery;
parse_str($finalQuery, $_GET);
$indexFile = $webRoot . 'index.php';
if (file_exists($indexFile)) {
    include_once $indexFile;
} else {
    http_response_code(404);
    echo "404 Not Found: public/index.php entry file does not exist";
}
exit;
// MIME type function (same as basic version)
function getMimeType($file) {
    $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
    $mimeMap = [
        'css' => 'text/css', 'js' => 'application/javascript',
        'html' => 'text/html', 'jpg' => 'image/jpeg',
        'png' => 'image/png', 'gif' => 'image/gif', 'ico' => 'image/x-icon'
    ];
    return isset($mimeMap[$extension]) ? $mimeMap[$extension] : null;
}
?>Grouping Logic: Store fixed URLs (e.g., /aboutus.html) in $exactRules and dynamic URLs (e.g., /([^/]+)\.html) in $generalRules to ensure exact rules are not overridden;
Order Within General Rules: Even in $generalRules, sort from "specific" to "broad" (e.g., match /xyd-([0-9]+)\.html first, then /([^/]+)\.html);
Parameter Compatibility: Retain query parameters from the original request (e.g., /user/abc?foo=bar → /index.php?ct=user&ac=abc&foo=bar), consistent with online rewriting habits.
Cause: The routing script does not prioritize processing static resources, or the $webRoot path is incorrectly concatenated (e.g., extra slashes);
Solution: Ensure the static resource judgment logic comes before the rewriting rules, and the $requestedFile path is in the format public/statics/ffsm/global.css.
Cause: Incorrect parameter order for the built-in server (e.g., router.php is placed before -t public), or incorrect regular expressions (e.g., unescaped .);
Solution: Strictly follow php -S [address] -t [root directory] [routing script], and use regular expressions in the format #^/aboutus\.html$#.
Problem: Syntax errors caused by using the ?? null coalescing operator;
Solution: Replace with isset() + ternary operator (e.g., $uriPath = isset($uriParts['path']) ? $uriParts['path'] : '/').
The core of URL rewriting for PHP's built-in server lies in the design of routing scripts. By adopting the approach of "static resources first + rule grouping and sorting", it can achieve rewriting functionality equivalent to Nginx/Apache. Key points are as follows:
Parameter Order: Strictly follow php -S [address] -t [root directory] [routing script] when starting;
Static Resources Priority: Prevent static resources from entering the rewriting process;
Rule Grouping: Sort by "exact → general" to resolve rule overriding issues;
Version Compatibility: Adjust syntax for older versions like PHP 5.6 to ensure project usability.
This solution can be directly reused for local development of mainstream frameworks such as ThinkPHP, eliminating the need for heavyweight web servers, effectively improving development efficiency, and reducing problems caused by environment differences.