Laravel без .env файлов

Источник: «Laravel without .env files»
Я работал с Laravel и Docker с 2016 по 2022 год. В течение всех этих лет у меня была небольшая проблема, связанная с переменными среды.

Мне приходилось жонглировать между .env, .env.testing, .env.dusk.{something}, phpunit.xml, phpunit.xml.dist и реальными переменными среды из контейнера Docker. Джейсон описал это лучше всего: Configuration precedence when testing Laravel.

Это была не повседневная проблема, но время от времени она меня раздражала. Также часто случалось, что когда я работал над проектом Laravel, в котором меня наняли для докернизации, команда начинала страдать от определения переменных среды для локальной разработки, для локального Docker, для CI/CD, для локального Dusk, для CI/CD Dusk и для production.

Недавно я попробовал новый подход к решению этой проблемы, и последние 3 месяца он мне очень нравится. Пока все отлично, но ещё рано принимать окончательное решение о том, насколько он хорош/плох.

В качестве побочного бонуса, этот подход позволил мне обойти проблему производительности phpdotenv, о которой сообщалось здесь: High performance impact on Parser #510. Последнее, чего я хочу — это кэшировать переменные среды, выполняя тесты phpunit весь день (TDD), а затем гоняться за невидимыми проблемами, вызванными кэшированной конфигурацией.

Чего я пытаюсь достичь

Мне нужно настроить Laravel с правильной конфигурацией, основанной на среде, в которой загружается Laravel. Я хочу избежать порядка старшинства и нескольких способов определения этих конфигураций, чтобы избежать неожиданного проникновения конфигурации в приложение. Процесс должен, как минимум, заботиться о:

Определение реальных переменных среды в локальной среде, Docker, GH Actions, CodeBuild и продакшен — все они разные. Я ищу что-то, что будет работать с минимальными усилиями на этих внешних системах.

Базовый класс Environment

Я решил определить класс со всеми атрибутами, которые мне нужны

<?php declare(strict_types=1);

namespace Packages\Environment;

use InvalidArgumentException;
use Packages\Environment\Bags\Aurora;
use Packages\Environment\Bags\Cognito;
use Packages\Environment\Bags\Dynamodb;
use Packages\Environment\Bags\Logstash;
use Packages\Environment\Bags\Queue;
use Packages\Environment\Bags\RedisSession;
use Packages\Environment\Bags\S3;
use Packages\Environment\Bags\Vite;
use Packages\Laravel\Testing\Minio;

abstract readonly class Environment
{
public RedisSession $session;

public Logstash $logstash;

public Aurora $aurora;

public Dynamodb $dynamodb;

public Cognito $cognito;

public Queue $queue;

public Minio $minio;

public S3 $s3;

public Vite $vite;

public DynamicEnvironment $secrets;
}

Имея такой базовый класс, я могу расширить его для создания конфигураций окружения, которые не должны быть динамическими и могут быть сохранены в системе контроля версий (vcs).

final readonly class MarcoLocal extends Environment implements Local
{
public function __construct(
public DynamicEnvironment $secrets,
public Aurora $aurora = new Aurora(
new AuroraConnection(
driver: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '123456',
database: 'my-project-main',
),
new AuroraConnection(
driver: 'mysql',
host: '127.0.0.1',
port: 3306,
username: 'root',
password: '123456',
database: 'my-project-tenant-1',
),
),
public RedisSession $session = new RedisSession(
host: '127.0.0.1',
port: 6379,
domain: '.my-project.test',
ssl: true,
),
public Logstash $logstash = new Logstash(
channel: 'single',
tcp: '',
udp: null,
fallback: '',
),
public Queue $queue = new Queue(
export: new QueueConnection('sync'),
import: new QueueConnection('sync'),
webhook: new QueueConnection('sync'),
),
public Minio $minio = new Minio(
host: '127.0.0.1',
port: 17811,
),
public Cognito $cognito = new Cognito(
userPoolId: 'local-fake-userPoolId',
appClientId: 'local-fake-appClientId',
privateKey: __DIR__ . '/Fixtures/jwt.phpunit.key',
publicKey: __DIR__ . '/Fixtures/jwt.phpunit.pub',
issuer: 'https://cognito-idp.local.amazonaws.com/phpunit-pool-id',
),
public S3 $s3 = new S3(
export: new S3Minio('export-bucket'),
recordings: new S3Minio('recording-bucket'),
),
public Vite $vite = new Vite(
cdn: 'https://cdn.my-project.test',
manifest: 'manifest.json',
),
) {}
}

Чтобы воспроизвести новую среду, мы расширяем базовый класс и заполняем всю необходимую информацию. У нас может быть несколько таких файлов для членов команды, и их рекомендуется (но не обязательно) коммитить в VCS, чтобы у нас был обзор того, как каждый настраивает своё окружение, будь то Linux, Windows или Mac, с Docker или без него и т. д.

Для целей локальной разработки и CI/CD мы считаем, что секретная информация MySQL необязательно должна быть секретной. Мы устанавливаем локальный MySQL, не доступный за пределами наших компьютеров (или используем контейнер MySQL), и используем стандартные имя пользователя/пароль для приложения, которые создаёт для нас сценарий установки.

Интерфейс Local пуст и просто помогает нам зарегистрировать некоторые локальные маршруты разработки в RouteServiceProvider. Это похоже на то, как Laravel Dusk регистрирует некоторые маршруты логина.

Что касается продакшена, то у нас есть 3 продакшен-региона (EU, US и AU), а также stage среда. Для них у нас будет несколько секретов.

Вот пример одного продакшена:

#[IgnoreClassForCodeCoverage(CI::class)]
final readonly class EU extends Environment implements Aws
{
public function __construct(
public DynamicEnvironment $secrets,
public Aurora $aurora = new Aurora(
main: new AuroraConnection(
driver: 'aurora', // Custom Laravel Driver that will connect using password from AWS Secret Manager
read: 'main.rds.my-project.internal',
write: 'read.main.rds.my-project.internal',
port: 3306,
username: 'my-project-username',
password: null,
database: 'my-project-main-database',
secret: 'my-project/eu/rds/main', // AWS Secret Manager identifier
),
tenant: new AuroraConnection(
driver: 'aurora',
read: 'main.rds.my-project.internal',
write: 'read.main.rds.my-project.internal',
port: 3306,
username: null,
password: null,
database: null,
secret: 'customergauge/eu/rds/tenant',
),
),
public RedisSession $session = new RedisSession(
host: 'redis.cache.my-project.internal',
port: 6379,
domain: '.eu.my-project.com',
ssl: true,
),
public Logstash $logstash = new Logstash(
channel: 'logstash',
tcp: 'tcp://logstash.my-project.internal:9601',
udp: 'udp://logstash.my-project.internal:9602',
fallback: 'https://sqs.eu-west-1.amazonaws.com/XXXXXXXXXXXX/logstash-service-LogstashFallbackQueue-XXXXXXXXXXXXXX',
),
public Queue $queue = new Queue(
export: new QueueConnection(
driver: 'sqs',
region: 'eu-west-1',
queue: 'https://sqs.eu-west-1.amazonaws.com/XXXXXXXXXXXX/export-queue-XXXXXXXXXXXXXX'
),
import: new QueueConnection(
driver: 'sqs',
region: 'eu-west-1',
queue: 'https://sqs.eu-west-1.amazonaws.com/XXXXXXXXXXXX/import-queue-XXXXXXXXXXXXXX'
),
webhook: new QueueConnection(
driver: 'sqs',
region: 'eu-west-1',
queue: 'https://sqs.eu-west-1.amazonaws.com/XXXXXXXXXXXX/webhook-queue-XXXXXXXXXXXXXX'
),
),
public Cognito $cognito = new Cognito(
userPoolId: 'eu-west-1_xxxxxxxxx',
appClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
privateKey: null, // AWS Cognito don't expose this information
publicKey: 'https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_xxxxxxxxx/.well-known/jwks.json',
issuer: 'https://cognito-idp.eu-west-1.amazonaws.com/eu-west-1_xxxxxxxxx',
),
public S3 $s3 = new S3(
export: new S3Bucket(
region: 'eu-west-1',
bucket: 'analytics-api-infrastructure-export-xxxxxxxxxxxxx',
),
recordings: new S3Bucket(
region: 'eu-west-1',
bucket: 'XXXXXXXXXXXXXX-XXXXXXXXXXXXXX-XXXXXXXXXXXXXX',
)
),
public Vite $vite = new Vite(
cdn: 'https://cdn.app.eu.my-project.com',
manifest: 'manifest.eu.json',
),
) {}
}

Загрузка конфигурации среды

В файле bootstrap/app.php я добавил следующие строки:

/*
|--------------------------------------------------------------------------
| My Project Environment Configuration
|--------------------------------------------------------------------------
|
| Let's make sure to detect which environment we're running on and set it
| up so that all the basic configuration such as database, queues, disk
| cache, session, etc. are properly configured for the current env.
|
*/


$environment = \Packages\Environment\Environment::fromEnvironment();

$app->singleton(\Packages\Environment\Environment::class);

$app->instance(\Packages\Environment\Environment::class, $environment);

Далее мне нужен метод fromEnvironment() в моем базовом классе Environment:

public static function fromEnvironment(): self
{
if (isset($_ENV['MY_PROJECT_ENV'])) {
return self::make($_ENV['MY_PROJECT_ENV']);
}

if (is_file(__DIR__ . '/.env.php')) {
$env = require __DIR__ . '/.env.php';

if (! $env instanceof Environment) {
dd('.env.php should return an instance of ' . Environment::class);
}

return $env;
}

$message = <<<'MESSAGE'
Could not determine the environment. For production, set the MY_PROJECT_ENV
environment variable. For local development, create a `.env.php` file in the
`my-project/src/packages/backend/Environment` directory. See `.env.example.php`.
MESSAGE;

dd($message);
}

Первые две строки метода ищут переменную среды, специфичную для проекта. Если она существует, значит, мы работаем в развёрнутом окружении (продакшен), и мы справимся с этим с помощью метода make, который можно увидеть ниже. Прежде чем я перейду к этому вопросу, давайте посмотрим на файл .env.php, который используется для локальной разработки:

<?php

if (defined('MY_PROJECT_PHPUNIT')) {
return new \Packages\Environment\MarcoPhpunit(
new \Packages\Environment\DynamicEnvironment(
appKey: 'base64:uE6UZaCqrqHYxpWiYW+8RkL/jRtBVj/YtoqKfKyAdpE=',
someThirdPartyApiKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx',
),
);
}

return new \Packages\Environment\MarcoLocal(
new \Packages\Environment\DynamicEnvironment(
appKey: 'base64:uE6UZaCqrqHYxpWiYW+8RkL/jRtBVj/YtoqKfKyAdpE=',
// Fill this up if you want to test SSO locally.
cognitoClientSecret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx',
someThirdPartyApiKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxx',
),
);

Это файл, который git игнорирует, поскольку он содержит секретную информацию, а также инстанцирует различные классы, поскольку у каждого разработчика будет своя собственная настройка классов окружения.

Константа MY_PROJECT_PHPUNIT — это один из удобных способов, который я нашёл, чтобы различать, запускаю ли я phpunit или запускаю проект локально. Эта константа определена в моем файле phpunit.xml и является единственной конфигурацией, которая там существует.

  <php>
<const name="MY_PROJECT_PHPUNIT" value="true"/>
</php>

Единственное различие между phpunit.xml.dist и phpunit.xml заключается в том, что локальная версия была подготовлена для сбора отчёта о тестовом покрытии в случае, если разработчик решит включить pcov на своей машине.

Оба файла внесены в VCS и не нуждаются в особом вмешательстве.

Наконец, давайте рассмотрим метод make класса Environment:

final public static function make(string $environment): self
{
return match ($environment) {
'staging' => new Staging(DynamicEnvironment::fromEnvironment()),
'eu' => new EU(DynamicEnvironment::fromEnvironment()),
'us' => new US(DynamicEnvironment::fromEnvironment()),
'au' => new AU(DynamicEnvironment::fromEnvironment()),

default => throw new InvalidArgumentException(
"Invalid environment [$environment]"
),
};
}

В живой среде AWS (будь то staging или production) нам нужно только настроить секреты как настоящие переменные среды и переменную MY_PROJECT_ENV. Это означает, что для локальной разработки и CI/CD у нас все закодировано и легко находится и читается. Нам не нужно настраивать переменные среды в .env, контейнерах Docker, GitHub Actions или AWS CodeBuild. Все они настраиваются в PHP-файле, а для продакшена мы настраиваем только несколько переменных, которые действительно являются ключами API, паролями или секретами.

public static function fromEnvironment(): self
{
if (! isset($_ENV['MY_PROJECT_APP_KEY'])) {
dd('MY_PROJECT_APP_KEY is not set');
}

return new self(
appKey: $_ENV['CUSTOMERGAUGE_APP_KEY'],
cognitoClientSecret: $_ENV['COGNITO_CLIENT_SECRET'],
SomeOtherApiService: $_ENV['SOME_OTHER_API_SERVICE'],
);
}

Настройка Laravel с помощью класса Environment

Теперь осталось собрать все эти конфигурации в Laravel. Для этого у меня есть класс ConfigurationServiceProvider, содержащий все необходимые конфигурации.

<?php declare(strict_types=1);

namespace Packages\Environment;

use App\Application;
use Aws\SesV2\SesV2Client;
use Database\Connector\TenantContext;
use Illuminate\Console\Events\CommandStarting;
use Illuminate\Container\Container;
use Illuminate\Contracts\Mail\Mailer as MailerContract;
use Illuminate\Database\DatabaseManager;
use Illuminate\Events\Dispatcher;
use Illuminate\Foundation\Vite;
use Illuminate\Http\Request;
use Illuminate\Mail\Mailer;
use Illuminate\Mail\Transport\SesV2Transport;
use Illuminate\Support\ServiceProvider;
use Packages\Laravel\Inertia\LazyPropWatcher;
use Packages\Notifications\RegionalMailer;
use Psr\Log\LoggerInterface;
use Illuminate\Support\Facades\Gate;
use Packages\Authorization\PolicyGate;

final class ConfigurationServiceProvider extends ServiceProvider
{
private Environment $environment;

public function register()
{
$this->environment = $this->app->make(Environment::class);

$this->registerApp();

$this->registerCdn();

$this->registerDatabaseMainConnector();

$this->registerQueueConnections();

$this->registerSessionConnection();

$this->registerRedisConnection();

$this->registerFileStorageDisks();

$this->app->singleton(LazyPropWatcher::class);

$this->registerNotifications();
}

private function registerApp(): void
{
$this->app['env'] = $this->environment->app;

config()->set('app.debug', $this->environment->debug);

config()->set('app.key', $this->environment->secrets->appKey);

$this->app->resolving(Vite::class, function (Vite $vite) {
$vite->useManifestFilename($this->environment->vite->manifest);
});
}

private function registerCdn(): void
{
config()->set('app.asset_url', $this->environment->vite->cdn);

$this->app->resolving(Vite::class, function (Vite $vite) {
$vite->useManifestFilename($this->environment->vite->manifest);
});
}

private function registerDatabaseMainConnector(): void
{
$this->app->resolving('db', function (DatabaseManager $manager, Application $app) {
$manager->extend('main', function (array $config, string $name) use ($app) {
/** @var \Illuminate\Database\Connectors\ConnectionFactory $factory */
$factory = $app->make('db.factory');

return $factory->make($this->environment->aurora->main->toConfig(), 'main');
});
});
}

private function registerQueueConnections(): void
{
config()->set('queue.connections.export', $this->environment->queue->export->toArray());
config()->set('queue.connections.import', $this->environment->queue->import->toArray());
config()->set('queue.connections.webhook', $this->environment->queue->webhook->toArray());
}

private function registerSessionConnection(): void
{
$region = $this->environment->region;

// This is used by the CSRF Token.
config()->set('database.redis.session', [
'host' => $this->environment->session->ip,
'port' => $this->environment->session->port,
'database' => 0,
]);

config()->set('session', [
'driver' => 'redis',
'connection' => 'session',
'lifetime' => 120,
'expire_on_close' => false,
'encrypt' => false,
'lottery' => [2, 100],
'cookie' => "my-project-$region-session",
'path' => '/',
'domain' => $this->environment->session->domainForLaravelSession(),
'secure' => $this->environment->session->secure,
'http_only' => true,
'same_site' => 'lax',
]);
}

private function registerRedisConnection(): void {}

private function registerFileStorageDisks(): void
{
config()->set('filesystems.disks.export', $this->environment->s3->export->toConfig());
}

private function registerNotifications(): void
{
$this->app->bind(MailerContract::class, function (Container $container) {
/** @var SesV2Client $client */
$client = $container->make(SesV2Client::class);

$option = [
'ConfigurationSetName' => 'CustomerGaugeServiceConfigSet',
'Tags' => [
['Name' => 'service', 'Value' => 'my-project'],
],
];

$transport = new SesV2Transport($client, $option);

$mailer = new Mailer('regional', $this->app['view'], $transport, $this->app['events']);

$mailer->setQueue($this->app['queue']);

return $mailer;
});

$this->app->bind(RegionalMailer::class, function (Container $container) {
$mailer = $container->make(MailerContract::class);

$environment = $container->make(Environment::class);

$logger = $container->make(LoggerInterface::class);

return new RegionalMailer($mailer, $environment, $logger);
});
}

public function boot(): void
{
/** @var Request $request */
$request = $this->app->make(Request::class);

$request->server->set('HTTPS', true);
}
}

Так как ConfigurationServiceProvider зависит от класса Environment, все безопасно с точки зрения типов, что является бонусом.

Отключение phpdotenv

Одним из первых действий Laravel при загрузке фреймворка является запуск Bootstrapper, запускающего пакет phpdotenv. Поскольку я нигде не использую функцию env(), я могу полностью пропустить эту часть. Я сделал так в файле Kernel.php:

class Kernel extends HttpKernel
{
public function __construct(Application $app, Router $router)
{
$this->bootstrappers = collect($this->bootstrappers)
->reject(\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class)
->toArray();

parent::__construct($app, $router);
}

// ...
}

То же самое можно сделать для ядра Http и ядра Console. Благодаря этому изменению Laravel не будет загружать пакет переменных среды, и я смогу на 100% сосредоточиться на настройке переменных среды.

Заключение

Я работаю с этой настройкой в своей команде уже 3 месяца. У нас есть пользователи Mac, Linux, Docker и Windows, и до сих пор мы никогда не говорили об этом, кроме как в первые дни настройки. Считаю это огромным успехом, потому что все просто настроили её один раз и с тех пор не говорили о ней.

За последние пару недель в базовом классе Environment произошло несколько изменений. Всякий раз, когда кто-то вносит в него изменения, они обязательно ломают остальные настройки, после git pull. Но поскольку все безопасно с точки зрения типов, мы получаем небольшой приятный Argument of type \Packages\Environment\Bags\QueueConnection missing... и очень просто зайти в мой личный файл и настроить его.

Время обработки автоматических тестов также немного увеличилось, потому что Laravel не нужно запускать процесс LoadEnvironmentVariables и считывать несколько переменных среды.

В целом, я очень доволен и не планирую возвращаться назад, однако меня немного беспокоят изменения в скелете Laravel 11. Будут ли там какие-то скрытые изменения, которые затронут меня? Если да, то напишу об этом в блоге и объясню, что именно сломалось и что я с этим сделал.

Дополнительные материалы

Предыдущая Статья

Плавная миграция от массива к объекту

Следующая Статья

Основные псевдоклассы фокуса :focus, :focus-within, и :focus-visible