Configuración con variables de entorno en PHP

Variables de entorno en PHP: la configuración que cambia de un servidor a otro, y sobre todo los secretos, no se escribe en el código. Se lee del entorno al arrancar, con tipos y comprobaciones, y el código es el mismo en tu ordenador y en producción.

Leer una variable: getenv

<?php
declare(strict_types=1);

var_dump(getenv('APP_ENTORNO'));
var_dump(getenv('NO_EXISTE'));
var_dump($_ENV['APP_ENTORNO'] ?? 'no está en $_ENV');
var_dump($_SERVER['APP_ENTORNO'] ?? 'no está en $_SERVER');
echo ini_get('variables_order'), "\n";
APP_ENTORNO=desarrollo php basico.php   (sin php.ini)string(10) "desarrollo"
bool(false)
string(10) "desarrollo"
string(10) "desarrollo"
EGPCS
APP_ENTORNO=desarrollo php -d variables_order=GPCS basico.php   (como en php.ini-production)string(10) "desarrollo"
bool(false)
string(17) "no está en $_ENV"
string(10) "desarrollo"
GPCS

getenv devuelve el valor como texto, o false si la variable no existe, y funciona igual con cualquier configuración. $_ENV, en cambio, depende de variables_order: sin ningún php.ini vale EGPCS y $_ENV trae las variables, pero las dos plantillas que trae PHP, php.ini-development y php.ini-production, lo cambian a GPCS, sin la E, y entonces $_ENV queda vacío. Por eso, en tu ordenador puede funcionar y en el servidor no. Usa getenv.

Una clase de configuración

Leer variables sueltas por todo el código acaba mal. Lo habitual es leerlas una vez, al arrancar, en un objeto con tipos, valores por omisión y errores claros:

<?php
declare(strict_types=1);

final readonly class Config
{
    public function __construct(
        public string $entorno,
        public bool $depuracion,
        public int $puerto,
        #[\SensitiveParameter] public string $claveApi,
    ) {}

    public static function desdeEntorno(): self
    {
        return new self(
            entorno: self::texto('APP_ENTORNO', 'produccion'),
            depuracion: filter_var(self::texto('APP_DEPURACION', 'false'), FILTER_VALIDATE_BOOL),
            puerto: filter_var(self::texto('APP_PUERTO', '8000'), FILTER_VALIDATE_INT) ?: throw new RuntimeException('APP_PUERTO no es un número'),
            claveApi: self::obligatoria('API_CLAVE'),
        );
    }

    private static function texto(string $nombre, string $omision): string
    {
        $v = getenv($nombre);
        return $v === false || $v === '' ? $omision : $v;
    }

    private static function obligatoria(string $nombre): string
    {
        $v = getenv($nombre);
        if ($v === false || $v === '') {
            throw new RuntimeException("Falta la variable de entorno $nombre");
        }
        return $v;
    }
}

try {
    $config = Config::desdeEntorno();
} catch (RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}

echo "Entorno: {$config->entorno}\n";
echo "Depuración: ", var_export($config->depuracion, true), "\n";
echo "Puerto: {$config->puerto}\n";
echo "Clave de la API: ", str_repeat('*', 8), substr($config->claveApi, -4), "\n";

Con todas las variables definidas:

APP_ENTORNO=desarrollo APP_DEPURACION=yes APP_PUERTO=8080 API_CLAVE=… php config.phpEntorno: desarrollo
Depuración: true
Puerto: 8080
Clave de la API: ********abcd

Solo con la obligatoria, las demás toman su valor por omisión:

API_CLAVE=… php config.phpEntorno: produccion
Depuración: false
Puerto: 8000
Clave de la API: ********wxyz

Y sin ella, el programa no arranca, con un mensaje claro y un código de salida distinto de 0 (aquí, 1):

php config.phpFalta la variable de entorno API_CLAVE

Detalles: todo llega como texto, así que FILTER_VALIDATE_BOOL convierte «yes», «true», «on» o «1» en true, y FILTER_VALIDATE_INT comprueba el número. La clave se muestra tapada, nunca entera, y #[\SensitiveParameter] la mantiene fuera de las trazas de error. Que falte una variable obligatoria es mejor detectarlo al arrancar que a mitad de una petición.

Los secretos no van en el código

Contraseñas de bases de datos, claves de API y tokens: nunca escritos en el código ni en el repositorio. Si se suben a Git, quedan en el historial aunque luego se borren, y cualquiera con acceso al repositorio (o a una copia filtrada) los tiene. En las variables de entorno, cada servidor tiene los suyos y el código es el mismo en todas partes.

Cómo se definen depende de dónde se ejecute PHP:

DóndeCómo
La terminalAPI_CLAVE=… php programa.php o export API_CLAVE=…
PHP-FPMenv[API_CLAVE] = … en el archivo del pool (con clear_env = no si vienen del sistema)
ApacheSetEnv API_CLAVE … en la configuración del sitio, fuera de la carpeta pública
Dockerdocker run -e API_CLAVE=… o environment: en Compose, mejor con secrets
Desarrollo localun archivo .env leído con la biblioteca vlucas/phpdotenv
El archivo .env, fuera de Git y fuera de la web. Si usas un .env, añádelo a .gitignore desde el primer día y deja en el repositorio solo un .env.example con los nombres y valores falsos. Y que no esté dentro de la carpeta pública: un .env descargable es una de las filtraciones de credenciales más frecuentes de la web.

Resumen

ParaUsa
Leer una variablegetenv('NOMBRE'), que devuelve false si no existe
Un sí o nofilter_var($v, FILTER_VALIDATE_BOOL)
Un númerofilter_var($v, FILTER_VALIDATE_INT)
Toda la configuraciónuna clase readonly que se crea al arrancar
Secretossolo en variables de entorno; .env fuera de Git y de la web

Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio, sin acceso a la red, antes de publicarla; las salidas y los mensajes de error están copiados de esa ejecución.