Back to blog

Security

Environment Configuration in PHP: .env Files, Secrets & Vault Integration

Best practices for managing config - dotenv libraries, secrets management with HashiCorp Vault, and runtime injection in containers.

  • PHP
  • Environment Configuration
  • Secrets
  • Vault
  • Security

Reader map

Key points in Environment Configuration in PHP: .env Files, Secrets & Vault Integration

Syntax first, runtime behavior second, migration cleanup last.

Read
16 min
Waypoints
7
Track
Security
  1. 01
    Start here

    Do not commit .env.

  2. 02
    Waypoint

    Do not bake .env into a Docker image.

  3. 03
    Waypoint

    Do not pass secrets through Docker ARG or persistent image ENV.

  4. 04
    Waypoint

    Do not log raw config arrays.

  5. 05
    Waypoint

    Do not read env() or getenv() all over the application.

  6. 06
    Waypoint

    Do not fetch Vault on every request.

  7. 07
    Migration check

    Do not rotate a secret unless the app has a reload or restart path.

SEO Metadata

SEO Title Options

  1. Environment Configuration in PHP: .env Files, Secrets
  2. PHP Security: Practical 2026 Guide
  3. Security Playbook: PHP Security

Meta Description Options

  1. Learn PHP Security with a practical Security framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Best practices for managing config - dotenv libraries, secrets management with HashiCorp Vault, and runtime injection in containers.

URL Slug

environment-configuration-php-env-files-secrets-vault-integration

Focus Keyword

PHP Security

Additional LSI Keywords

  • Security
  • PHP
  • Environment Configuration
  • Secrets
  • Vault
  • Environment Configuration in PHP: .env Files, Secrets & Vault Integration
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

PHP Security is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP Security should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP Security expert guide for Security]

What PHP Security means

PHP Security means applying security knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For security topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP Security implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP Security as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP Security common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP Security with input, decision boundary, implementation, tests, and production feedback. Alt: PHP Security concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Environment Configuration in PHP: .env Files, Secrets & Vault Integration. Alt: PHP Security mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP Security comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP Security.]

Internal linking opportunities

Original Technical Deep Dive

Configuration is not a pile of strings.

It is the contract between the deploy environment and the PHP process. If that contract is vague, every deploy becomes a guessing game: missing keys, wrong database hosts, leaked tokens, debug mode in production, and secrets copied into places they never should have reached.

This guide covers a practical PHP setup:

  • .env files for local development
  • real environment variables for deployed apps
  • typed configuration objects
  • validation at boot
  • secret files mounted into containers
  • HashiCorp Vault integration
  • rotation and redaction rules

This guide was reviewed on May 7, 2026 against PHP, vlucas/phpdotenv, Symfony, Docker, Kubernetes, HashiCorp Vault, and Twelve-Factor App documentation.

The short version

Use this rule set:

NeedLocal developmentProduction
Non-secret config.env or shell envorchestrator environment
Secret valueslocal .env, password manager, or local secret filesecret manager or mounted secret file
Build-time secretsBuildKit secret mountBuildKit secret mount
Runtime secretsmounted file or env varmounted file, Vault Agent, cloud secret manager, or orchestrator secret
PHP accesstyped config objecttyped config object
Validationfail at bootfail at boot

Hard rules:

  • Do not commit .env.
  • Do not bake .env into a Docker image.
  • Do not pass secrets through Docker ARG or persistent image ENV.
  • Do not log raw config arrays.
  • Do not read env() or getenv() all over the application.
  • Do not fetch Vault on every request.
  • Do not rotate a secret unless the app has a reload or restart path.

The application should read configuration once during boot, validate it, then pass typed values to services.

Config is not the same as secrets

Configuration includes anything that changes by environment:

  • APP_ENV
  • APP_DEBUG
  • APP_URL
  • DATABASE_HOST
  • REDIS_HOST
  • MAIL_TRANSPORT
  • FEATURE_BILLING_ENABLED

Secrets are configuration values with confidentiality requirements:

  • database passwords
  • API tokens
  • webhook signing secrets
  • private keys
  • OAuth client secrets
  • encryption keys

Treat them differently. Non-secret config can be visible in deployment manifests. Secrets need restricted access, rotation, audit trails, and redaction.

Keep .env local

Use .env to make local development predictable.

APP_ENV=local
APP_DEBUG=true
APP_URL=http://127.0.0.1:8080

DATABASE_DSN="mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4"
DATABASE_USER=app
DATABASE_PASSWORD=local-password

REDIS_HOST=127.0.0.1
REDIS_PORT=6379

VAULT_ADDR=http://127.0.0.1:8200

Commit .env.example, not .env:

APP_ENV=local
APP_DEBUG=false
APP_URL=

DATABASE_DSN=
DATABASE_USER=
DATABASE_PASSWORD=

REDIS_HOST=127.0.0.1
REDIS_PORT=6379

VAULT_ADDR=

Add ignore rules:

.env
.env.*
!.env.example

Do not put production secrets in .env.example. Example files document required keys. They are not a secret transport.

[IMAGE: Supporting visual 1 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 1]

[IMAGE: Supporting visual 1 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 1]

Load .env only when present

For a plain PHP app using vlucas/phpdotenv:

composer require vlucas/phpdotenv

Bootstrap:

<?php

declare(strict_types=1);

use Dotenv\Dotenv;

require __DIR__ . '/../vendor/autoload.php';

$root = dirname(__DIR__);

if (is_file($root . '/.env')) {
    Dotenv::createImmutable($root)->load();
}

That keeps production free to use real runtime environment variables. A missing .env is not an error in a deployed container.

If every environment should be allowed to load a file when available but keep running when absent, use:

Dotenv::createImmutable($root)->safeLoad();

Then validate the required keys separately.

Validate environment keys

phpdotenv can enforce required values and simple formats:

<?php

declare(strict_types=1);

$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->safeLoad();

$dotenv->required([
    'APP_ENV',
    'APP_DEBUG',
    'APP_URL',
    'DATABASE_DSN',
    'DATABASE_USER',
])->notEmpty();

$dotenv->required('APP_ENV')->allowedValues(['local', 'staging', 'production']);
$dotenv->required('APP_DEBUG')->isBoolean();
$dotenv->ifPresent('REDIS_PORT')->isInteger();

Validation should happen before the app accepts traffic. A bad deploy should fail fast, not limp along until the first payment webhook, queue job, or database query.

Read config through one object

Do not let controllers and services call getenv() directly.

Bad:

$client = new StripeClient(getenv('STRIPE_SECRET_KEY'));

Better:

$client = new StripeClient($config->stripeSecretKey);

Create one reader at the edge:

<?php

declare(strict_types=1);

final readonly class Env
{
    public function required(string $key): string
    {
        $value = $this->optional($key);

        if ($value === null || $value === '') {
            throw new RuntimeException("Missing required environment variable: {$key}");
        }

        return $value;
    }

    public function optional(string $key, ?string $default = null): ?string
    {
        $value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);

        if ($value === false || $value === null) {
            return $default;
        }

        return (string) $value;
    }

    public function bool(string $key, bool $default = false): bool
    {
        $value = $this->optional($key);

        if ($value === null || $value === '') {
            return $default;
        }

        return match (strtolower($value)) {
            '1', 'true', 'yes', 'on' => true,
            '0', 'false', 'no', 'off' => false,
            default => throw new RuntimeException("Invalid boolean environment variable: {$key}"),
        };
    }

    public function int(string $key, int $default): int
    {
        $value = $this->optional($key);

        if ($value === null || $value === '') {
            return $default;
        }

        if (filter_var($value, FILTER_VALIDATE_INT) === false) {
            throw new RuntimeException("Invalid integer environment variable: {$key}");
        }

        return (int) $value;
    }

    public function secret(string $key): string
    {
        $file = $this->optional($key . '_FILE');

        if ($file !== null && $file !== '') {
            return $this->readSecretFile($file, $key);
        }

        return $this->required($key);
    }

    private function readSecretFile(string $path, string $key): string
    {
        if (! is_file($path) || ! is_readable($path)) {
            throw new RuntimeException("Secret file for {$key} is not readable.");
        }

        $value = file_get_contents($path);

        if ($value === false) {
            throw new RuntimeException("Secret file for {$key} could not be read.");
        }

        $value = trim($value);

        if ($value === '') {
            throw new RuntimeException("Secret file for {$key} is empty.");
        }

        return $value;
    }
}

The _FILE convention lets the same PHP code support local .env, Docker Compose secrets, Kubernetes mounted secrets, and Vault Agent rendered files.

Build a typed config object

Create a stable application contract:

<?php

declare(strict_types=1);

final readonly class AppConfig
{
    public function __construct(
        public string $environment,
        public bool $debug,
        public string $baseUrl,
        public DatabaseConfig $database,
        public RedisConfig $redis,
        public VaultConfig $vault,
    ) {}

    public static function fromEnv(Env $env): self
    {
        return new self(
            environment: $env->required('APP_ENV'),
            debug: $env->bool('APP_DEBUG'),
            baseUrl: rtrim($env->required('APP_URL'), '/'),
            database: new DatabaseConfig(
                dsn: $env->required('DATABASE_DSN'),
                username: $env->required('DATABASE_USER'),
                password: $env->secret('DATABASE_PASSWORD'),
            ),
            redis: new RedisConfig(
                host: $env->required('REDIS_HOST'),
                port: $env->int('REDIS_PORT', 6379),
            ),
            vault: new VaultConfig(
                address: $env->optional('VAULT_ADDR'),
                tokenFile: $env->optional('VAULT_TOKEN_FILE'),
            ),
        );
    }
}

final readonly class DatabaseConfig
{
    public function __construct(
        public string $dsn,
        public string $username,
        public string $password,
    ) {}
}

final readonly class RedisConfig
{
    public function __construct(
        public string $host,
        public int $port,
    ) {}
}

final readonly class VaultConfig
{
    public function __construct(
        public ?string $address,
        public ?string $tokenFile,
    ) {}
}

Now the rest of the app receives typed objects:

$env = new Env();
$config = AppConfig::fromEnv($env);

$pdo = new PDO(
    $config->database->dsn,
    $config->database->username,
    $config->database->password,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
);

This removes stringly typed configuration from the domain layer.

Do not dump config

Never log the full config object.

Add explicit redaction:

<?php

declare(strict_types=1);

final readonly class SafeConfigSummary
{
    /**
     * @return array<string, scalar|null>
     */
    public static function from(AppConfig $config): array
    {
        return [
            'environment' => $config->environment,
            'debug' => $config->debug,
            'base_url' => $config->baseUrl,
            'database_dsn_hash' => hash('sha256', $config->database->dsn),
            'database_user' => $config->database->username,
            'redis_host' => $config->redis->host,
            'redis_port' => $config->redis->port,
            'vault_enabled' => $config->vault->address !== null,
        ];
    }
}

Log whether config loaded, not the secret values.

Runtime injection in containers

The image should be immutable. Runtime configuration belongs outside the image.

Bad Dockerfile:

COPY .env /var/www/html/.env
ENV DATABASE_PASSWORD=prod-password
ARG PRIVATE_REPO_TOKEN

Better:

FROM php:8.4-fpm-alpine

WORKDIR /var/www/html

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress

COPY src ./src
COPY public ./public
COPY config ./config

USER www-data

Inject config when the container starts:

services:
  app:
    image: registry.example.com/php-app:2026-04-25
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      APP_URL: https://example.com
      DATABASE_DSN: mysql:host=mysql;dbname=app;charset=utf8mb4
      DATABASE_USER: app
      DATABASE_PASSWORD_FILE: /run/secrets/database_password
      REDIS_HOST: redis
      REDIS_PORT: "6379"
    secrets:
      - database_password

secrets:
  database_password:
    file: ./secrets/database_password.txt

Compose mounts the secret as a file under /run/secrets/<name>. Your Env::secret() method reads it through DATABASE_PASSWORD_FILE.

Build-time secrets are different

Sometimes builds need a token for private Composer repositories.

Do not do this:

ARG COMPOSER_AUTH
ENV COMPOSER_AUTH=$COMPOSER_AUTH
RUN composer install

Use a BuildKit secret mount:

# syntax=docker/dockerfile:1.7

RUN --mount=type=secret,id=composer_auth,target=/tmp/auth.json \
    COMPOSER_AUTH="$(cat /tmp/auth.json)" \
    composer install --no-dev --prefer-dist --no-interaction --no-progress

Build:

docker build \
  --secret id=composer_auth,src="$HOME/.config/composer/auth.json" \
  -t registry.example.com/php-app:2026-04-25 .

Build secrets are available only for that build instruction. They should not become image layers, labels, ENV, or committed files.

Kubernetes secrets are not a vault by default

Kubernetes Secret objects are useful, but they are not enough by themselves.

Important operational facts:

  • Kubernetes stores Secrets in the API server data store.
  • By default, they are not encrypted at rest.
  • Anyone with broad API access can read them.
  • Anyone allowed to create Pods in a namespace may be able to mount Secrets from that namespace.
  • Base64 in a manifest is encoding, not encryption.

[IMAGE: Supporting visual 2 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 2]

Use Kubernetes Secrets with:

  • encryption at rest enabled
  • tight RBAC
  • namespace boundaries
  • mounted files instead of broad environment exposure when possible
  • an external secret store for higher-risk credentials

[IMAGE: Supporting visual 2 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 2]

Example file mount:

apiVersion: v1
kind: Pod
metadata:
  name: php-app
spec:
  containers:
    - name: app
      image: registry.example.com/php-app:2026-04-25
      env:
        - name: DATABASE_PASSWORD_FILE
          value: /run/secrets/database_password
      volumeMounts:
        - name: app-secrets
          mountPath: /run/secrets
          readOnly: true
  volumes:
    - name: app-secrets
      secret:
        secretName: php-app-secrets

The PHP process still reads the same _FILE path.

Vault integration options

There are three common Vault patterns for PHP:

PatternHow it worksBest fitMain risk
Vault Agent templateAgent authenticates and renders secrets to filesFPM apps, containers, Kubernetesapp must reload or restart after rotation
Direct Vault clientPHP authenticates and reads Vault APICLI workers, daemons, apps needing dynamic credentialstoken renewal and startup dependency
Orchestrator syncplatform syncs Vault into Kubernetes or container secretsteams standardizing secret deliverysync lag and duplicated secret surface

For PHP-FPM applications, Vault Agent templates are usually simpler than direct Vault calls from every request.

Vault Agent rendered file

Vault Agent can authenticate, renew its own token, and render templates to files.

Template file:

{{- with secret "kv/data/production/php-app" -}}
DATABASE_PASSWORD="{{ .Data.data.database_password }}"
STRIPE_SECRET_KEY="{{ .Data.data.stripe_secret_key }}"
WEBHOOK_SIGNING_SECRET="{{ .Data.data.webhook_signing_secret }}"
{{- end -}}

Agent config:

vault {
  address = "https://vault.example.com"
}

auto_auth {
  method "approle" {
    mount_path = "auth/approle"

    config = {
      role_id_file_path = "/run/secrets/vault_role_id"
      secret_id_file_path = "/run/secrets/vault_secret_id"
    }
  }

  sink "file" {
    config = {
      path = "/run/secrets/vault_token"
    }
  }
}

template {
  source = "/vault/templates/app.env.ctmpl"
  destination = "/run/secrets/app.env"
  perms = "0400"
  command = "kill -USR2 $(cat /run/php-fpm.pid)"
}

Then load the rendered file at process start:

$secretFile = '/run/secrets/app.env';

if (is_file($secretFile)) {
    Dotenv\Dotenv::createImmutable(dirname($secretFile), basename($secretFile))->load();
}

This keeps the application out of the business of storing Vault credentials, renewing Vault tokens, and retrying Vault outages during every HTTP request.

Do not blindly reload on every file write. For PHP-FPM, a graceful worker reload is usually cleaner than mutating already-running service objects.

Vault in Kubernetes

The Vault Agent Injector can run an agent sidecar and inject rendered secrets into a pod.

Example annotations:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: php-app
spec:
  template:
    metadata:
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "php-app"
        vault.hashicorp.com/agent-inject-secret-app.env: "kv/data/production/php-app"
        vault.hashicorp.com/agent-inject-template-app.env: |
          {{- with secret "kv/data/production/php-app" -}}
          DATABASE_PASSWORD="{{ .Data.data.database_password }}"
          STRIPE_SECRET_KEY="{{ .Data.data.stripe_secret_key }}"
          {{- end -}}
    spec:
      serviceAccountName: php-app
      containers:
        - name: app
          image: registry.example.com/php-app:2026-04-25
          env:
            - name: APP_ENV
              value: production
            - name: DATABASE_DSN
              value: mysql:host=mysql;dbname=app;charset=utf8mb4

The app reads the injected file. The pod's service account and Vault role define what it is allowed to read.

Direct Vault reads from PHP

Use direct Vault reads only when the application can own the operational complexity:

  • authentication
  • token renewal
  • request timeouts
  • Vault outage behavior
  • cache invalidation
  • lease renewal for dynamic secrets
  • revocation on shutdown

A minimal read-only client using PHP stream contexts:

<?php

declare(strict_types=1);

final readonly class VaultClient
{
    public function __construct(
        private string $address,
        private string $token,
        private float $timeoutSeconds = 2.0,
    ) {}

    /**
     * @return array<string, mixed>
     */
    public function kvV2(string $mount, string $path): array
    {
        $url = rtrim($this->address, '/') . '/v1/' . rawurlencode($mount) . '/data/' . ltrim($path, '/');

        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'timeout' => $this->timeoutSeconds,
                'header' => [
                    'X-Vault-Token: ' . $this->token,
                    'Accept: application/json',
                ],
            ],
        ]);

        $body = file_get_contents($url, false, $context);

        if ($body === false) {
            throw new RuntimeException('Vault request failed.');
        }

        $payload = json_decode($body, true, flags: JSON_THROW_ON_ERROR);

        if (! is_array($payload) || ! isset($payload['data']['data']) || ! is_array($payload['data']['data'])) {
            throw new RuntimeException('Vault response did not contain kv-v2 data.');
        }

        return $payload['data']['data'];
    }
}

Usage:

$token = trim(file_get_contents('/run/secrets/vault_token') ?: '');

if ($token === '') {
    throw new RuntimeException('Vault token is missing.');
}

$vault = new VaultClient(
    address: $env->required('VAULT_ADDR'),
    token: $token,
);

$secrets = $vault->kvV2('kv', 'production/php-app');

This is intentionally small. A production direct client should handle non-200 responses, JSON error payloads, retry policy, metrics, and token renewal. If that list feels excessive, use Vault Agent.

AppRole is for machines

[IMAGE: Supporting visual 3 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 3]

Vault AppRole is designed for machines and services. A login uses a role_id and usually a secret_id to receive a Vault token.

Do not place both long-lived values in the same image, repository, or static manifest. Better approaches:

  • Role ID from non-secret environment or metadata.
  • Secret ID delivered through a separate trusted path.
  • Short TTL Secret IDs.
  • Limited Secret ID use count.
  • Narrow Vault policies.
  • Response wrapping where a trusted orchestrator delivers a one-time wrapping token.

[IMAGE: Supporting visual 3 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 3]

If the app runs in Kubernetes, prefer the Kubernetes auth method or Vault Agent Injector over manually distributing AppRole credentials to every pod.

Dynamic secrets need lease handling

Vault can issue dynamic secrets, such as database credentials with leases. That changes the PHP design.

Static secret:

DATABASE_PASSWORD=same value until rotated

Dynamic secret:

DATABASE_USER=generated-user
DATABASE_PASSWORD=generated-password
lease expires in 1 hour

For dynamic credentials, the app must handle:

  • lease renewal before expiry
  • reconnecting pooled database connections
  • retrying failed requests safely
  • revoking credentials when a worker shuts down
  • restarting workers when renewal fails

For classic PHP-FPM, dynamic database credentials are often easier with a sidecar or process manager that restarts workers after secret rotation. Long-running workers need explicit renewal logic.

Rotation strategy

Every secret needs a rotation plan before it is used in production.

SecretRotation approach
Database passwordcreate second user, deploy both-compatible config, switch, remove old user
API tokenissue new token, deploy, revoke old token
Webhook signing secretaccept old and new during transition, then remove old
Encryption keyuse key IDs and envelope encryption; do not re-encrypt blindly in request path
Vault AppRole SecretIDshort TTL and limited uses

Code for dual webhook secrets:

<?php

declare(strict_types=1);

final readonly class WebhookSecrets
{
    /**
     * @param non-empty-list<string> $secrets
     */
    public function __construct(
        private array $secrets,
    ) {}

    public static function fromEnv(Env $env): self
    {
        $values = [
            $env->secret('WEBHOOK_SIGNING_SECRET'),
        ];

        $previous = $env->optional('WEBHOOK_SIGNING_SECRET_PREVIOUS');

        if ($previous !== null && $previous !== '') {
            $values[] = $previous;
        }

        return new self($values);
    }

    public function verify(string $payload, string $signature): bool
    {
        foreach ($this->secrets as $secret) {
            $expected = hash_hmac('sha256', $payload, $secret);

            if (hash_equals($expected, $signature)) {
                return true;
            }
        }

        return false;
    }
}

Rotation is not only infrastructure. The application sometimes needs compatibility windows.

CI and secret scanning

CI should prove the contract without real secrets.

Example .env.test:

APP_ENV=test
APP_DEBUG=false
APP_URL=http://localhost
DATABASE_DSN=sqlite::memory:
DATABASE_USER=test
DATABASE_PASSWORD=test
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Test boot:

<?php

declare(strict_types=1);

it('loads test configuration', function (): void {
    Dotenv\Dotenv::createImmutable(dirname(__DIR__), '.env.test')->load();

    $config = AppConfig::fromEnv(new Env());

    expect($config->environment)->toBe('test')
        ->and($config->debug)->toBeFalse()
        ->and($config->database->dsn)->toBe('sqlite::memory:');
});

Also add repository controls:

  • .env is ignored.
  • .env.example is committed.
  • secret scanning is enabled in the Git host.
  • CI fails if likely secrets appear in tracked files.
  • deployment logs redact sensitive keys.

Simple local check:

git ls-files | rg '(^|/)\\.env$' && exit 1

Use a real scanner such as Gitleaks, TruffleHog, GitHub secret scanning, or your platform's equivalent for production workflows.

[IMAGE: Supporting visual 4 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 4]

Configuration checklist

Use this before every deploy:

  • APP_ENV is correct.
  • APP_DEBUG=false outside local development.
  • .env is not in the image.
  • Docker build secrets are passed through BuildKit secret mounts.
  • Runtime secrets are injected by the orchestrator or Vault.
  • PHP validates required values on boot.
  • Secret file paths are readable only by the PHP runtime user.
  • Logs redact secrets and connection URLs.
  • Vault policies are least privilege.
  • Vault tokens, leases, and Secret IDs have TTLs.
  • Rotation has a documented rollback path.
  • Tests boot from a fake environment without production secrets.

Common mistakes

[IMAGE: Supporting visual 4 for Environment Configuration in PHP: .env Files, Secrets & Vault Integration, showing PHP Security decisions, examples, and PHP, Environment Configuration, Secrets. Alt: PHP Security environment-configuration-php-env-files-secrets-vault-integration visual 4]

Mistake: Commit .env and rotate only the current secret.

Fix: Remove the file from Git history if possible, rotate every secret that ever appeared in it, and add secret scanning.

Mistake: Use .env.production inside the Docker image.

Fix: keep the same image across environments and inject config at runtime.

Mistake: Read getenv() deep inside services.

Fix: read configuration once, create typed config objects, and inject dependencies.

Mistake: Store secrets in Kubernetes and assume base64 means safe.

Fix: enable encryption at rest, restrict RBAC, and consider an external secret store.

Mistake: Fetch Vault secrets during every request.

Fix: use Vault Agent, a local cache with refresh behavior, or fetch at worker boot.

Mistake: Rotate a database password without a connection restart plan.

Fix: use dual credentials or orchestrated worker restarts.

FAQ

What is PHP Security?

PHP Security is a practical security topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP Security?

Use PHP Security when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP Security?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP Security?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP Security affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP Security is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top