Laravel без .env файлов
- Чего я пытаюсь достичь
- Базовый класс
Environment
- Загрузка конфигурации среды
- Настройка Laravel с помощью класса
Environment
- Отключение
phpdotenv
- Заключение
Мне приходилось жонглировать между .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. Я хочу избежать порядка старшинства и нескольких способов определения этих конфигураций, чтобы избежать неожиданного проникновения конфигурации в приложение. Процесс должен, как минимум, заботиться о:
- Локальной разработке
- Локальном PHPUnit
- Разных разработчиках (с Docker или без него)
- PHPUnit на CI/CD (GitHub Actions и AWS CodeBuild)
- Продакшене (AWS Lambda и AWS Fargate)
Определение реальных переменных среды в локальной среде, 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. Будут ли там какие-то скрытые изменения, которые затронут меня? Если да, то напишу об этом в блоге и объясню, что именно сломалось и что я с этим сделал.