A legacy PHP site's include 'lib/db.php' calls break after a script moves into a subdirectory; why, and what is the robust fix?
answer
- relative to what, exactly?
- include_path entries, then the including file's directory
- . in include_path is the working directory
- web SAPIs chdir; the CLI does not
- __DIR__ . '/../lib/db.php'
basics
~20 sA relative include is resolved against include_path, whose '.' means the current working directory, and then the including file's directory, not the project root. Moving the script changes those; building paths from DIR makes them independent of both.
solid answer
~40 sA relative path in `include`/`require` is not relative to the project. A path starting with `./` or `../` is resolved against the **current working directory** only. Any other relative path, such as `'lib/db.php'`, is tried against each entry of `include_path` (usually `.` first, meaning the working directory) and finally against the **directory of the file containing the include**. Under a web SAPI the working directory is the directory of the requested script; the CLI keeps the shell's directory. So when `admin.php` moves to `admin/index.php`, `lib/db.php` is looked up under `admin/`, and nested includes inside `lib/` may even pick up a same-named file from the wrong directory. The fix is to anchor every path to the file that contains it: `require __DIR__ . '/../lib/db.php';`, or one bootstrap that defines the root and an autoloader.
code
php · 9 lines<?php
declare(strict_types=1);
// admin/index.php, moved here from the site root
echo getcwd(), PHP_EOL; // web SAPI: /var/www/site/admin
echo get_include_path(), PHP_EOL; // e.g. .:/usr/share/php
// include 'lib/db.php'; // looks in ./ (admin/), include_path, then admin/
require __DIR__ . '/../lib/db.php'; // always /var/www/site/lib/db.phpgo deeper
Remember to build include paths from DIR rather than writing bare relative paths.
Explain the resolution order: absolute, explicit ./ and ../ against the working directory, plain names through include_path then the including file's directory.
Diagnose the silent variant where a same-named file in the working directory wins, and remember that CLI and web runs resolve differently.
Plan legacy cleanup as path anchoring plus an autoloader and a single bootstrap, so directory moves stop being risky refactors.
## The scenario A legacy site has `/var/www/site/admin.php` with `include 'lib/db.php';`, and `lib/db.php` in turn does `include 'config.php';`. Everything works. Then `admin.php` moves to `/var/www/site/admin/index.php`, and the page fails with `Failed opening 'lib/db.php' for inclusion`. Worse, some pages still load, but with the wrong configuration. Both symptoms come from how PHP resolves relative include paths. ## How PHP resolves an include path PHP distinguishes three kinds of path: | Path written | Resolved against | |---|---| | Absolute: `/var/www/site/lib/db.php` | nothing, used as is | | Explicitly relative: `./lib/db.php`, `../lib/db.php` | the **current working directory** only; `include_path` is ignored | | Plain relative: `lib/db.php` | each `include_path` entry in order, then the **directory of the file doing the include** | `include_path` is an ini directive holding a list of directories separated by `PATH_SEPARATOR` (`:` on Unix, `;` on Windows). Its compiled-in default usually starts with `.`, and `.` means the **current working directory**, not the directory of any file. `set_include_path()` changes it for the rest of the request and returns the old value (or `false` on failure); `get_include_path()` reads it. ## What the working directory is - Under web SAPIs such as PHP-FPM, CGI and the Apache module, PHP changes the working directory to the **directory of the requested script** before running it. - The **CLI** does not change it; it stays wherever the command was started. A cron job running `php /var/www/site/admin.php` keeps the directory cron started it in, not `/var/www/site`. So the same file can resolve includes differently from the browser and from cron. ## Why the move breaks things After the move, the requested script is `/var/www/site/admin/index.php`: 1. `include 'lib/db.php';` tries `./lib/db.php` via the `.` entry, which is now `/var/www/site/admin/lib/db.php`. Not there. 2. It tries the other `include_path` entries, typically a shared library directory. Not there. 3. It tries the including file's own directory, `/var/www/site/admin/`. Not there. The include fails. Now suppose a developer "fixes" it with `include '../lib/db.php';`. That works from the browser, because the working directory is `admin/`, but fails from a CLI job started elsewhere. Inside `lib/db.php`, `include 'config.php';` is resolved via `.` first, which is still `admin/`. If an unrelated `admin/config.php` exists, **that** file is loaded, silently, instead of `lib/config.php`. The fallback to the including file's directory is only consulted when the earlier candidates fail. ## The robust fix Anchor every path to the file that contains it with the `__DIR__` magic constant, which is the directory of the file it is written in, whatever the working directory: ```php <?php // admin/index.php require __DIR__ . '/../lib/db.php'; // lib/db.php $config = require __DIR__ . '/config.php'; ``` Good practice around it: - **One entry point per kind of request** (a front controller and a CLI bootstrap) that defines the project root once. - **An autoloader** for classes, so moving a script never touches class-loading paths. - **No reliance on `include_path`** in application code; it is global, shared with other code, and changes meaning with the working directory. - **No `chdir()`** in library code to make relative includes work; it changes the resolution for every later include in the request. ## Resolution at a glance | Include written as | Working directory matters? | include_path matters? | Moves safely? | |---|---|---|---| | `'lib/db.php'` | yes, via `.` | yes | no | | `'./lib/db.php'`, `'../lib/db.php'` | yes | no | no | | `'/var/www/site/lib/db.php'` | no | no | only while the site stays put | | `__DIR__ . '/lib/db.php'` | no | no | **yes**, relative to the file | ## A migration checklist for a legacy site 1. Grep for `include`, `require` and their `_once` forms with a path that does not start with `__DIR__`, a constant or a variable. 2. Rewrite each to `__DIR__ . '/relative/path.php'` from the file that contains it. 3. Search for `set_include_path()`, `ini_set('include_path', ...)` and `chdir()` calls and plan their removal. 4. Run the same entry points from the browser **and** from the CLI, since the working directory differs. 5. Introduce an autoloader for classes so that later moves only touch the autoloader's mapping. ## Diagnosing it - The warning's `(include_path='...')` part shows the path list that was searched. - `getcwd()` shows the working directory at the failing line, which is often the surprise. - `get_included_files()` shows which `config.php` was actually loaded.
- In PHP, what is the difference between include './helpers.php' and include 'helpers.php'?`./helpers.php` is explicitly relative, so PHP looks only in the current working directory and ignores `include_path`. `helpers.php` is a plain relative name: PHP tries each `include_path` entry in order (usually `.` first) and then the directory of the file containing the include. The second form can therefore find a file the first one misses, or a different file with the same name.
- In PHP, why can a script that works in the browser fail when the same file runs from cron via the CLI?Web SAPIs set the working directory to the requested script's directory, while the CLI keeps the directory the command was started from. Any path that depends on the working directory, such as `./x.php`, `../x.php` or `.` in `include_path`, then points somewhere else. Paths built from `__DIR__` behave the same in both.
- In PHP, what does set_include_path() return, and how long does the change last?It returns the previous include path as a string on success, or `false` on failure, so callers can restore it. The new value lasts for the rest of the current request (or CLI run) only; the next request starts from the ini setting again.
saying these in an interview costs you the question
- A relative include is always resolved from the project root
- include 'x.php' only ever looks in the including file's directory
- The . in include_path means the directory of the current file
- The working directory is the same under the CLI and under PHP-FPM
- Adding more directories to include_path is the clean fix for moved scripts