Summary
With \Tina4\Middleware\CorsMiddleware registered globally, cross-origin requests that require a CORS preflight (e.g. POST with Authorization and Content-Type: application/json) fail in the browser because the OPTIONS preflight gets a 404 response. Simple requests (e.g. GET /api/foo) work fine.
Reproduction
index.php:
<?php
require "./vendor/autoload.php";
\Tina4\Middleware::use(\Tina4\Middleware\CorsMiddleware::class);
\$app = new \Tina4\App(__DIR__);
\$app->handle();
src/routes/login.php:
Router::post("/api/login", function (\$request, \$response) {
return \$response(['ok' => true]);
})->secure(false);
.env:
TINA4_CORS_ORIGINS=http://localhost:3000
Then:
$ curl -i -X OPTIONS -H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" \
http://127.0.0.1:7146/api/login
HTTP/1.1 404 Not Found
...
Browser reports: "CORS Preflight Did Not Succeed" and the real POST never fires.
Root cause
Two interacting issues in Tina4/Router.php:
-
CorsMiddleware::beforeCors sets status 204 on OPTIONS (see CorsMiddleware.php:210-212), but Router::dispatchInner only short-circuits middleware on status >= 400:
if (\$response->getStatusCode() >= 400) {
return \$response;
}
204 is not >= 400, so dispatch continues into match(\$request->method, \$request->path).
-
Router::any() only registers GET|POST|PUT|PATCH|DELETE — there is no way to register a catch-all OPTIONS handler:
public static function any(string \$path, callable \$callback): self
{
foreach (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as \$method) {
self::addRoute(\$method, \$path, \$callback);
}
...
}
So match() returns null for every OPTIONS request, dispatchInner falls through to renderError(\$response, 404, 'Not Found', \$request->path), and the 204 from beforeCors is replaced with a 404 JSON body.
Because renderError's json() / html() preserve existing headers, the CORS headers are still there — but the browser rejects the preflight because the status is not 2xx.
Suggested fix
Any one of these would resolve it:
-
Short-circuit OPTIONS after CORS middleware runs. In dispatchInner, after the CORS pre-pass, if \$request->method === 'OPTIONS' and the CORS middleware set a 2xx status, return immediately without running the other middleware or hitting match():
if (strtoupper(\$request->method) === 'OPTIONS' && \$response->getStatusCode() < 400) {
return \$response;
}
-
Include OPTIONS in Router::any()'s method list — so users can at least register a catch-all preflight handler manually.
-
Add Router::options() as a sibling to get/post/put/patch/delete.
Option 1 is the most correct because it matches what every CORS-aware framework does and doesn't require users to touch their route files. Options 2 and 3 are useful regardless of option 1 for users who want full control.
Workaround
Until this is fixed, a manual short-circuit at the top of index.php works:
if ((\$_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
\$origin = \$_SERVER['HTTP_ORIGIN'] ?? '';
\$allowed = array_map('trim', explode(',', \Tina4\DotEnv::getEnv('TINA4_CORS_ORIGINS', '*') ?? '*'));
\$resolved = in_array('*', \$allowed, true) ? '*'
: (in_array(\$origin, \$allowed, true) ? \$origin : null);
if (\$resolved !== null) {
header("Access-Control-Allow-Origin: {\$resolved}");
header('Access-Control-Allow-Methods: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_METHODS', 'GET,POST,PUT,PATCH,DELETE,OPTIONS'));
header('Access-Control-Allow-Headers: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_HEADERS', 'Content-Type,Authorization,X-Request-ID'));
header('Access-Control-Max-Age: ' . \Tina4\DotEnv::getEnv('TINA4_CORS_MAX_AGE', '86400'));
if (\$resolved !== '*') {
header('Vary: Origin');
header('Access-Control-Allow-Credentials: true');
}
}
http_response_code(204);
exit;
}
This bypasses the framework entirely for preflight, which defeats the point of CorsMiddleware.
Version observed: tina4stack/tina4php v3.10.85
Summary
With
\Tina4\Middleware\CorsMiddlewareregistered globally, cross-origin requests that require a CORS preflight (e.g.POSTwithAuthorizationandContent-Type: application/json) fail in the browser because theOPTIONSpreflight gets a404response. Simple requests (e.g.GET /api/foo) work fine.Reproduction
index.php:src/routes/login.php:.env:Then:
Browser reports: "CORS Preflight Did Not Succeed" and the real
POSTnever fires.Root cause
Two interacting issues in
Tina4/Router.php:CorsMiddleware::beforeCorssets status 204 on OPTIONS (seeCorsMiddleware.php:210-212), butRouter::dispatchInneronly short-circuits middleware on status>= 400:204 is not
>= 400, so dispatch continues intomatch(\$request->method, \$request->path).Router::any()only registersGET|POST|PUT|PATCH|DELETE— there is no way to register a catch-all OPTIONS handler:So
match()returnsnullfor every OPTIONS request,dispatchInnerfalls through torenderError(\$response, 404, 'Not Found', \$request->path), and the 204 frombeforeCorsis replaced with a 404 JSON body.Because renderError's
json()/html()preserve existing headers, the CORS headers are still there — but the browser rejects the preflight because the status is not 2xx.Suggested fix
Any one of these would resolve it:
Short-circuit OPTIONS after CORS middleware runs. In
dispatchInner, after the CORS pre-pass, if\$request->method === 'OPTIONS'and the CORS middleware set a 2xx status, return immediately without running the other middleware or hittingmatch():Include OPTIONS in
Router::any()'s method list — so users can at least register a catch-all preflight handler manually.Add
Router::options()as a sibling toget/post/put/patch/delete.Option 1 is the most correct because it matches what every CORS-aware framework does and doesn't require users to touch their route files. Options 2 and 3 are useful regardless of option 1 for users who want full control.
Workaround
Until this is fixed, a manual short-circuit at the top of
index.phpworks:This bypasses the framework entirely for preflight, which defeats the point of
CorsMiddleware.Version observed: tina4stack/tina4php v3.10.85