매일 쓰는데 설명 못 하는 것
User::create() 한 줄에 Observer가 어떻게 호출되는지 이전 글에서 따라가봤다. 그 글을 쓰고 나서 더 근본적인 질문이 떠올랐다.
Nginx가 /index.php로 보낸 후, Laravel은 어떻게 원래 URL을 아는가?
브라우저가 /api/cars?page=2를 요청한다. Nginx의 try_files가 결국 /index.php로 넘긴다. 그러면 Laravel은 어떻게 /api/cars로 라우팅하는 걸까?
처음엔 이렇게 생각했다 — index.php가 URL을 파싱하는 거 아닌가?
아니었다.
잘못 알기 쉬운 모델
PHP 개발자가 흔히 갖는 모델:
브라우저 → Nginx → /index.php
↓
index.php가 URL 문자열을 파싱
↓
Router
이 모델대로면 index.php가 어딘가에서 /api/cars라는 문자열을 읽어와야 한다. 어디서?
public/index.php를 열어보면 핵심은 한 줄이다.
$app->handleRequest(Request::capture());
URL 문자열을 직접 다루는 코드는 없다. Request::capture()가 어딘가에서 가져온다.
진짜 흐름
브라우저 요청: GET /api/cars?page=2
↓
Nginx
- try_files: 실제 파일 없음 → /index.php 실행 결정
- 단, REQUEST_URI는 /api/cars?page=2 그대로 보존
↓
php-fpm
- public/index.php 실행
- PHP 전역 변수($_SERVER, $_GET 등)에 Nginx가 넘긴 값들이 채워짐
↓
Laravel
- Request::capture() — $_SERVER, $_GET, $_POST를 읽어 Request 객체 생성
- $app->handleRequest($request) — Kernel → Router 매칭
↓
Route::get('/api/cars', [CarController::class, 'index']);
↓
Controller 실행
핵심은 Nginx가 PHP 전역 변수에 원래 URL 정보를 박아준다는 것. index.php는 그걸 읽기만 한다.
PHP 전역 변수의 두 얼굴
php-fpm이 index.php를 실행할 때 $_SERVER에는 대략 이런 값이 들어 있다.
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/api/cars?page=2';
$_SERVER['SCRIPT_FILENAME'] = '/var/www/laravel-bb/public/index.php';
$_SERVER['SCRIPT_NAME'] = '/index.php';
$_SERVER['QUERY_STRING'] = 'page=2';
$_GET['page'] = '2';
여기서 헷갈리면 안 되는 두 변수:
| 변수 | 의미 | 값 |
|---|---|---|
SCRIPT_FILENAME | 실행할 PHP 파일 (Nginx가 FastCGI param으로 명시) | /var/www/laravel-bb/public/index.php |
REQUEST_URI | 사용자가 실제로 요청한 URL | /api/cars?page=2 |
Nginx 설정에서 fastcgi_param SCRIPT_FILENAME ...을 박는 이유가 이거다. 어떤 PHP 파일을 실행할지는 Nginx가 결정해서 명시적으로 알려준다. 반면 원래 URL은 자동으로 REQUEST_URI에 살아남는다.
이 분리가 핵심이다.
Request::capture() 한 줄을 따라가본다
Illuminate\Http\Request::capture()의 본질:
public static function capture()
{
static::enableHttpMethodParameterOverride();
return static::createFromBase(SymfonyRequest::createFromGlobals());
}
SymfonyRequest::createFromGlobals()가 진짜 핵심. 이름 그대로 PHP 전역 변수에서 Request 객체를 만든다.
내부에서:
$request = self::createRequestFromFactory(
$_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER
);
즉 PHP 전역 6개 ($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER + 내부)를 읽어 Symfony Request 객체로 wrap한다.
이렇게 만들어진 Request는 다음과 같이 사용 가능:
$request->method(); // 'GET'
$request->getRequestUri(); // '/api/cars?page=2'
$request->path(); // 'api/cars'
$request->query('page'); // '2'
Router는 Request를 어떻게 보는가
$app->handleRequest($request)가 호출되면 내부적으로 HTTP Kernel을 거쳐 Router에 도달한다. Router는 등록된 라우트와 비교한다.
Route::get('/api/cars', [CarController::class, 'index']);
비교 기준은 본질적으로 두 개:
- HTTP method —
$request->method()=== ‘GET’? - path —
$request->path()=== ‘api/cars’?
매칭되면 컨트롤러를 실행한다. 그게 다다.
복잡해 보이지만 문자열 매칭이다. index.php가 어떤 URL을 받았는지 신경 쓰지 않아도 되는 이유는, Symfony Request가 PHP 전역에서 다 읽어와서 객체로 만들어줬기 때문이다.
1줄 디버깅 — 직접 확인해보기
Laravel 컨트롤러 안에서 한 번만 박아봐도 모델이 잡힌다.
public function index(Request $request)
{
dd([
'method' => $request->method(),
'uri' => $request->getRequestUri(),
'path' => $request->path(),
'script' => $_SERVER['SCRIPT_FILENAME'],
'all_server' => $_SERVER,
]);
}
SCRIPT_FILENAME은 index.php 절대경로, uri는 원래 사용자 URL. 둘이 다른 게 보인다.
응용 — API prefix는 어디서 결정되나
같은 메커니즘 위에서 Laravel API prefix를 다루면 어디서 결정되는지 보인다.
bootstrap/app.php:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
apiPrefix: '', // 또는 'api'
...
)
routes/api.php:
Route::prefix('v1')->group(function () {
Route::get('/cars', [CarController::class, 'index']);
});
이 두 가지가 합쳐져 최종 path가 만들어진다:
| apiPrefix | Route::prefix | 최종 path |
|---|---|---|
'api' (기본) | 'v1' | /api/v1/cars |
'' | 'v1' | /v1/cars |
'api' | 없음 | /api/cars |
기본값 'api'를 빈 문자열로 바꾸는 결정도 자주 본다. URL이 /api/v1/...으로 가는 것보다 /v1/...이 깔끔하다는 이유.
다만 Router 입장에선 단순 path 매칭이다. apiPrefix도 결국 prefix 추가일 뿐.
정리 — try_files와 Laravel은 분리되어 있다
이전 글에서 try_files / proxy_pass / fastcgi_pass의 차이를 정리했다. 그 글이 Nginx가 어디로 보내는가를 다뤘다면, 이 글은 보낸 후 Laravel 안에서 무슨 일이 일어나는가다.
핵심 분리:
Nginx 책임 (infrastructure 층)
- 어떤 PHP 파일을 실행할지 결정 (SCRIPT_FILENAME)
- 원래 URL은 REQUEST_URI에 보존
- php-fpm에 전달
Laravel 책임 (application 층)
- Request::capture()로 PHP 전역에서 Request 객체 생성
- Router가 method + path로 라우트 매칭
- 컨트롤러 실행
Nginx의 try_files가 보낸 결과는 index.php 실행이지만, 원래 URL 정보는 그대로 살아남는다. 이게 PHP의 30년 패턴이고, Laravel이 그 위에 얹혀 있는 이유다.
결론
매일 쓰면서 설명 못 했던 것 — Nginx → /index.php → Laravel Router 의 흐름. 한 줄로 정리하면:
Nginx가 PHP 전역 변수에 URL을 박아주고,
Request::capture()가 그걸 객체로 wrap하고, Router가 그 객체의 path로 매칭한다.
index.php는 URL을 파싱하지 않는다. 읽어 객체로 만들 뿐이다. 그 분리를 알면 Nginx 설정이든 Laravel 라우팅이든 더 이상 마법이 아니다.
참고
- Observer가 호출되는 진짜 타이밍 — 라라벨 소스로 따라가본다 — 같은 source-reading 시리즈
- Laravel + Next SSR — 웹서버를 어디에 띄울 것인가 — Nginx 측 흐름
- Symfony HttpFoundation Request
- Laravel
Illuminate\Http\Request::capture()소스 public/index.php진입점 코드