diff --git a/.gitignore b/.gitignore index 53ff2a89..a8eee64f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ env.d.ts smoke-tests/pnpm-lock.yaml .magent eslint-output.txt + +# PHP +vendor/ +composer.lock +*.cache +examples/laravel-app/.env +examples/symfony-app/.env.local +examples/symfony-app/.env.dev diff --git a/examples/.varlock/manifest.json b/examples/.varlock/manifest.json new file mode 100644 index 00000000..d32e1db5 --- /dev/null +++ b/examples/.varlock/manifest.json @@ -0,0 +1,78 @@ +{ + "version": "1.0.0", + "generatedAt": "2026-03-14T00:00:00Z", + "items": { + "APP_NAME": { + "type": "string", + "required": false, + "sensitive": false, + "default": "Varlock Demo" + }, + "APP_ENV": { + "type": "string", + "required": true, + "sensitive": false, + "default": "local" + }, + "APP_DEBUG": { + "type": "boolean", + "required": false, + "sensitive": false, + "default": "true" + }, + "APP_KEY": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_KEY" + } + }, + "APP_SECRET": { + "type": "string", + "required": false, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_SECRET" + } + }, + "DB_HOST": { + "type": "string", + "required": true, + "sensitive": false, + "default": "127.0.0.1" + }, + "DB_PORT": { + "type": "integer", + "required": false, + "sensitive": false, + "default": "3306" + }, + "DB_DATABASE": { + "type": "string", + "required": true, + "sensitive": false, + "default": "varlock_demo" + }, + "DB_PASSWORD": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "DB_PASSWORD" + } + }, + "CACHE_TTL": { + "type": "number", + "required": false, + "sensitive": false, + "default": "3600" + } + } +} diff --git a/examples/laravel-app/.editorconfig b/examples/laravel-app/.editorconfig new file mode 100644 index 00000000..a186cd20 --- /dev/null +++ b/examples/laravel-app/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/examples/laravel-app/.env.example b/examples/laravel-app/.env.example new file mode 100644 index 00000000..c0660ea1 --- /dev/null +++ b/examples/laravel-app/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/examples/laravel-app/.gitattributes b/examples/laravel-app/.gitattributes new file mode 100644 index 00000000..fcb21d39 --- /dev/null +++ b/examples/laravel-app/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/examples/laravel-app/.gitignore b/examples/laravel-app/.gitignore new file mode 100644 index 00000000..b71b1ea3 --- /dev/null +++ b/examples/laravel-app/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/examples/laravel-app/.varlock/manifest.json b/examples/laravel-app/.varlock/manifest.json new file mode 100644 index 00000000..d32e1db5 --- /dev/null +++ b/examples/laravel-app/.varlock/manifest.json @@ -0,0 +1,78 @@ +{ + "version": "1.0.0", + "generatedAt": "2026-03-14T00:00:00Z", + "items": { + "APP_NAME": { + "type": "string", + "required": false, + "sensitive": false, + "default": "Varlock Demo" + }, + "APP_ENV": { + "type": "string", + "required": true, + "sensitive": false, + "default": "local" + }, + "APP_DEBUG": { + "type": "boolean", + "required": false, + "sensitive": false, + "default": "true" + }, + "APP_KEY": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_KEY" + } + }, + "APP_SECRET": { + "type": "string", + "required": false, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_SECRET" + } + }, + "DB_HOST": { + "type": "string", + "required": true, + "sensitive": false, + "default": "127.0.0.1" + }, + "DB_PORT": { + "type": "integer", + "required": false, + "sensitive": false, + "default": "3306" + }, + "DB_DATABASE": { + "type": "string", + "required": true, + "sensitive": false, + "default": "varlock_demo" + }, + "DB_PASSWORD": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "DB_PASSWORD" + } + }, + "CACHE_TTL": { + "type": "number", + "required": false, + "sensitive": false, + "default": "3600" + } + } +} diff --git a/examples/laravel-app/README.md b/examples/laravel-app/README.md new file mode 100644 index 00000000..0165a773 --- /dev/null +++ b/examples/laravel-app/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/examples/laravel-app/app/Http/Controllers/Controller.php b/examples/laravel-app/app/Http/Controllers/Controller.php new file mode 100644 index 00000000..8677cd5c --- /dev/null +++ b/examples/laravel-app/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/examples/laravel-app/app/Providers/AppServiceProvider.php b/examples/laravel-app/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..452e6b65 --- /dev/null +++ b/examples/laravel-app/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/examples/laravel-app/bootstrap/app.php b/examples/laravel-app/bootstrap/app.php new file mode 100644 index 00000000..4eba9fdb --- /dev/null +++ b/examples/laravel-app/bootstrap/app.php @@ -0,0 +1,28 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); + +// Varlock: run AFTER Dotenv loads .env but BEFORE config files are parsed. +// This ensures secrets resolved from external sources (1Password, Vault, etc.) +// are available when config/app.php calls env('APP_KEY'), etc. +$app->afterBootstrapping(LoadEnvironmentVariables::class, function () use ($app) { + \Varlock\Laravel\VarlockBootstrap::load($app->basePath()); +}); + +return $app; diff --git a/examples/laravel-app/bootstrap/cache/.gitignore b/examples/laravel-app/bootstrap/cache/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/bootstrap/providers.php b/examples/laravel-app/bootstrap/providers.php new file mode 100644 index 00000000..fc94ae60 --- /dev/null +++ b/examples/laravel-app/bootstrap/providers.php @@ -0,0 +1,7 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/examples/laravel-app/config/auth.php b/examples/laravel-app/config/auth.php new file mode 100644 index 00000000..d7568ff1 --- /dev/null +++ b/examples/laravel-app/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/examples/laravel-app/config/cache.php b/examples/laravel-app/config/cache.php new file mode 100644 index 00000000..b32aead2 --- /dev/null +++ b/examples/laravel-app/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/examples/laravel-app/config/database.php b/examples/laravel-app/config/database.php new file mode 100644 index 00000000..64709ce5 --- /dev/null +++ b/examples/laravel-app/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/examples/laravel-app/config/filesystems.php b/examples/laravel-app/config/filesystems.php new file mode 100644 index 00000000..37d8fca4 --- /dev/null +++ b/examples/laravel-app/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/examples/laravel-app/config/logging.php b/examples/laravel-app/config/logging.php new file mode 100644 index 00000000..9e998a49 --- /dev/null +++ b/examples/laravel-app/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/examples/laravel-app/config/mail.php b/examples/laravel-app/config/mail.php new file mode 100644 index 00000000..e32e88da --- /dev/null +++ b/examples/laravel-app/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/examples/laravel-app/config/queue.php b/examples/laravel-app/config/queue.php new file mode 100644 index 00000000..79c2c0a2 --- /dev/null +++ b/examples/laravel-app/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/examples/laravel-app/config/services.php b/examples/laravel-app/config/services.php new file mode 100644 index 00000000..6a90eb83 --- /dev/null +++ b/examples/laravel-app/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/examples/laravel-app/config/session.php b/examples/laravel-app/config/session.php new file mode 100644 index 00000000..5b541b75 --- /dev/null +++ b/examples/laravel-app/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/examples/laravel-app/database/.gitignore b/examples/laravel-app/database/.gitignore new file mode 100644 index 00000000..9b19b93c --- /dev/null +++ b/examples/laravel-app/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/examples/laravel-app/database/factories/UserFactory.php b/examples/laravel-app/database/factories/UserFactory.php new file mode 100644 index 00000000..c4ceb074 --- /dev/null +++ b/examples/laravel-app/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/examples/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php b/examples/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 00000000..05fb5d9e --- /dev/null +++ b/examples/laravel-app/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/examples/laravel-app/database/migrations/0001_01_01_000001_create_cache_table.php b/examples/laravel-app/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 00000000..ed758bdf --- /dev/null +++ b/examples/laravel-app/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/examples/laravel-app/database/migrations/0001_01_01_000002_create_jobs_table.php b/examples/laravel-app/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 00000000..967fbf5e --- /dev/null +++ b/examples/laravel-app/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + + $table->index(['queue', 'reserved_at', 'available_at']); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/examples/laravel-app/database/seeders/DatabaseSeeder.php b/examples/laravel-app/database/seeders/DatabaseSeeder.php new file mode 100644 index 00000000..6b901f8b --- /dev/null +++ b/examples/laravel-app/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/examples/laravel-app/package.json b/examples/laravel-app/package.json new file mode 100644 index 00000000..7686b295 --- /dev/null +++ b/examples/laravel-app/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } +} diff --git a/examples/laravel-app/phpunit.xml b/examples/laravel-app/phpunit.xml new file mode 100644 index 00000000..e7f0a48d --- /dev/null +++ b/examples/laravel-app/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/examples/laravel-app/public/.htaccess b/examples/laravel-app/public/.htaccess new file mode 100644 index 00000000..b574a597 --- /dev/null +++ b/examples/laravel-app/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/examples/laravel-app/public/favicon.ico b/examples/laravel-app/public/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/examples/laravel-app/public/index.php b/examples/laravel-app/public/index.php new file mode 100644 index 00000000..ee8f07e9 --- /dev/null +++ b/examples/laravel-app/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/examples/laravel-app/public/robots.txt b/examples/laravel-app/public/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/examples/laravel-app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/examples/laravel-app/resources/css/app.css b/examples/laravel-app/resources/css/app.css new file mode 100644 index 00000000..3e6abeab --- /dev/null +++ b/examples/laravel-app/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/examples/laravel-app/resources/js/app.js b/examples/laravel-app/resources/js/app.js new file mode 100644 index 00000000..e59d6a0a --- /dev/null +++ b/examples/laravel-app/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/examples/laravel-app/resources/js/bootstrap.js b/examples/laravel-app/resources/js/bootstrap.js new file mode 100644 index 00000000..5f1390b0 --- /dev/null +++ b/examples/laravel-app/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/examples/laravel-app/resources/views/welcome.blade.php b/examples/laravel-app/resources/views/welcome.blade.php new file mode 100644 index 00000000..b7355d72 --- /dev/null +++ b/examples/laravel-app/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/examples/laravel-app/routes/console.php b/examples/laravel-app/routes/console.php new file mode 100644 index 00000000..3c9adf1a --- /dev/null +++ b/examples/laravel-app/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/examples/laravel-app/routes/web.php b/examples/laravel-app/routes/web.php new file mode 100644 index 00000000..73a03874 --- /dev/null +++ b/examples/laravel-app/routes/web.php @@ -0,0 +1,54 @@ +getManifestItems(); + $values = $state->all(); + + $result = []; + foreach ($items as $key => $schema) { + $value = $values[$key] ?? null; + $result[$key] = [ + 'type' => $schema['type'] ?? 'string', + 'required' => $schema['required'] ?? false, + 'sensitive' => $schema['sensitive'] ?? false, + 'value' => $state->isSensitive($key) ? '[REDACTED]' : $value, + ]; + } + + return response()->json($result); +}); + +Route::get('/varlock/log-test', function () { + $dbPassword = env('DB_PASSWORD', '(not set)'); + \Illuminate\Support\Facades\Log::info("Connecting to database with password: {$dbPassword}"); + + return response()->json([ + 'message' => 'Log entry written. Check storage/logs/laravel.log — the password should be redacted.', + ]); +}); + +Route::get('/varlock/validation-test', function () { + $state = \Varlock\Core\VarlockState::getInstance(); + $items = $state->getManifestItems(); + + // Show what would fail if required vars were missing + $requiredVars = []; + foreach ($items as $key => $schema) { + if ($schema['required'] ?? false) { + $requiredVars[] = $key; + } + } + + return response()->json([ + 'required_vars' => $requiredVars, + 'all_present' => true, + 'note' => 'If any required var were missing, VarlockBootstrap would throw VarlockValidationException at boot.', + ]); +}); diff --git a/examples/laravel-app/storage/app/.gitignore b/examples/laravel-app/storage/app/.gitignore new file mode 100644 index 00000000..fedb287f --- /dev/null +++ b/examples/laravel-app/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/examples/laravel-app/storage/app/private/.gitignore b/examples/laravel-app/storage/app/private/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/app/public/.gitignore b/examples/laravel-app/storage/app/public/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/framework/.gitignore b/examples/laravel-app/storage/framework/.gitignore new file mode 100644 index 00000000..05c4471f --- /dev/null +++ b/examples/laravel-app/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/examples/laravel-app/storage/framework/cache/.gitignore b/examples/laravel-app/storage/framework/cache/.gitignore new file mode 100644 index 00000000..01e4a6cd --- /dev/null +++ b/examples/laravel-app/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/examples/laravel-app/storage/framework/cache/data/.gitignore b/examples/laravel-app/storage/framework/cache/data/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/framework/sessions/.gitignore b/examples/laravel-app/storage/framework/sessions/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/framework/testing/.gitignore b/examples/laravel-app/storage/framework/testing/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/framework/views/.gitignore b/examples/laravel-app/storage/framework/views/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/storage/logs/.gitignore b/examples/laravel-app/storage/logs/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/laravel-app/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/laravel-app/tests/Feature/ExampleTest.php b/examples/laravel-app/tests/Feature/ExampleTest.php new file mode 100644 index 00000000..8364a84e --- /dev/null +++ b/examples/laravel-app/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/examples/laravel-app/tests/TestCase.php b/examples/laravel-app/tests/TestCase.php new file mode 100644 index 00000000..fe1ffc2f --- /dev/null +++ b/examples/laravel-app/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/examples/laravel-app/vite.config.js b/examples/laravel-app/vite.config.js new file mode 100644 index 00000000..f35b4e79 --- /dev/null +++ b/examples/laravel-app/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); diff --git a/examples/mock-secret-server.php b/examples/mock-secret-server.php new file mode 100644 index 00000000..bf09515a --- /dev/null +++ b/examples/mock-secret-server.php @@ -0,0 +1,63 @@ + 'base64:9McFRwiu6WCB21XjXdjz2b3njJCsnsVS6Qmz9FbdDGk=', + 'APP_SECRET' => 'whsec_a1b2c3d4e5f6g7h8i9j0', + 'DB_PASSWORD' => 'prod-db-P@ssw0rd!-2026-rotated', +]; + +$host = '127.0.0.1'; +$port = 9777; + +echo "Varlock mock secret server running on http://{$host}:{$port}\n"; +echo "Serving " . count($secrets) . " secrets (APP_KEY, APP_SECRET, DB_PASSWORD)\n"; +echo "Press Ctrl+C to stop.\n\n"; + +$server = stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr); +if (!$server) { + fwrite(STDERR, "Failed to start server: {$errstr} ({$errno})\n"); + exit(1); +} + +while ($conn = stream_socket_accept($server, -1)) { + $request = fread($conn, 4096); + + // Parse the request path + preg_match('/GET\s+(\S+)/', $request, $matches); + $path = $matches[1] ?? '/'; + + $timestamp = date('H:i:s'); + + if ($path === '/secrets') { + $body = json_encode($secrets); + $response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n{$body}"; + echo "[{$timestamp}] 200 GET /secrets — returned all secrets\n"; + } elseif (preg_match('#^/secrets/(\w+)$#', $path, $m) && isset($secrets[$m[1]])) { + $body = $secrets[$m[1]]; + $response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " . strlen($body) . "\r\n\r\n{$body}"; + echo "[{$timestamp}] 200 GET /secrets/{$m[1]}\n"; + } else { + $body = '{"error": "not found"}'; + $response = "HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n{$body}"; + echo "[{$timestamp}] 404 GET {$path}\n"; + } + + fwrite($conn, $response); + fclose($conn); +} diff --git a/examples/symfony-app/.editorconfig b/examples/symfony-app/.editorconfig new file mode 100644 index 00000000..66990769 --- /dev/null +++ b/examples/symfony-app/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/examples/symfony-app/.env b/examples/symfony-app/.env new file mode 100644 index 00000000..dde976e5 --- /dev/null +++ b/examples/symfony-app/.env @@ -0,0 +1,34 @@ +# In all environments, the following files are loaded if they exist, +# the latter taking precedence over the former: +# +# * .env contains default values for the environment variables needed by the app +# * .env.local uncommitted file with local overrides +# * .env.$APP_ENV committed environment-specific defaults +# * .env.$APP_ENV.local uncommitted environment-specific overrides +# +# Real environment variables win over .env files. +# +# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. +# https://symfony.com/doc/current/configuration/secrets.html +# +# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). +# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration + +###> symfony/framework-bundle ### +APP_ENV=dev +# Secrets below are resolved from secret manager via varlock manifest — no values needed here +APP_SECRET= +APP_KEY= +APP_SHARE_DIR=var/share +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=varlock_demo +DB_PASSWORD= +CACHE_TTL=3600 +###< symfony/framework-bundle ### + +###> symfony/routing ### +# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. +# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands +DEFAULT_URI=http://localhost +###< symfony/routing ### diff --git a/examples/symfony-app/.gitignore b/examples/symfony-app/.gitignore new file mode 100644 index 00000000..a67f91e2 --- /dev/null +++ b/examples/symfony-app/.gitignore @@ -0,0 +1,10 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### diff --git a/examples/symfony-app/.varlock/manifest.json b/examples/symfony-app/.varlock/manifest.json new file mode 100644 index 00000000..d32e1db5 --- /dev/null +++ b/examples/symfony-app/.varlock/manifest.json @@ -0,0 +1,78 @@ +{ + "version": "1.0.0", + "generatedAt": "2026-03-14T00:00:00Z", + "items": { + "APP_NAME": { + "type": "string", + "required": false, + "sensitive": false, + "default": "Varlock Demo" + }, + "APP_ENV": { + "type": "string", + "required": true, + "sensitive": false, + "default": "local" + }, + "APP_DEBUG": { + "type": "boolean", + "required": false, + "sensitive": false, + "default": "true" + }, + "APP_KEY": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_KEY" + } + }, + "APP_SECRET": { + "type": "string", + "required": false, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_SECRET" + } + }, + "DB_HOST": { + "type": "string", + "required": true, + "sensitive": false, + "default": "127.0.0.1" + }, + "DB_PORT": { + "type": "integer", + "required": false, + "sensitive": false, + "default": "3306" + }, + "DB_DATABASE": { + "type": "string", + "required": true, + "sensitive": false, + "default": "varlock_demo" + }, + "DB_PASSWORD": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "DB_PASSWORD" + } + }, + "CACHE_TTL": { + "type": "number", + "required": false, + "sensitive": false, + "default": "3600" + } + } +} diff --git a/examples/symfony-app/bin/console b/examples/symfony-app/bin/console new file mode 100755 index 00000000..d8d530e2 --- /dev/null +++ b/examples/symfony-app/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php +=8.4", + "ext-ctype": "*", + "ext-iconv": "*", + "symfony/console": "8.0.*", + "symfony/dotenv": "8.0.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "8.0.*", + "symfony/monolog-bundle": "^4.0", + "symfony/runtime": "8.0.*", + "symfony/yaml": "8.0.*", + "varlock/symfony-bundle": "@dev" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*", + "symfony/polyfill-php83": "*", + "symfony/polyfill-php84": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "8.0.*" + } + }, + "repositories": { + "varlock-symfony": { + "type": "path", + "url": "../../packages/sdks/php-symfony" + }, + "varlock-core": { + "type": "path", + "url": "../../packages/sdks/php-core" + } + } +} diff --git a/examples/symfony-app/config/bundles.php b/examples/symfony-app/config/bundles.php new file mode 100644 index 00000000..2a105d6c --- /dev/null +++ b/examples/symfony-app/config/bundles.php @@ -0,0 +1,7 @@ + ['all' => true], + Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], + Varlock\Symfony\VarlockBundle::class => ['all' => true], +]; diff --git a/examples/symfony-app/config/packages/cache.yaml b/examples/symfony-app/config/packages/cache.yaml new file mode 100644 index 00000000..6899b720 --- /dev/null +++ b/examples/symfony-app/config/packages/cache.yaml @@ -0,0 +1,19 @@ +framework: + cache: + # Unique name of your app: used to compute stable namespaces for cache keys. + #prefix_seed: your_vendor_name/app_name + + # The "app" cache stores to the filesystem by default. + # The data in this cache should persist between deploys. + # Other options include: + + # Redis + #app: cache.adapter.redis + #default_redis_provider: redis://localhost + + # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues) + #app: cache.adapter.apcu + + # Namespaced pools use the above "app" backend by default + #pools: + #my.dedicated.cache: null diff --git a/examples/symfony-app/config/packages/framework.yaml b/examples/symfony-app/config/packages/framework.yaml new file mode 100644 index 00000000..7e1ee1f1 --- /dev/null +++ b/examples/symfony-app/config/packages/framework.yaml @@ -0,0 +1,15 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Note that the session will be started ONLY if you read or write from it. + session: true + + #esi: true + #fragments: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/examples/symfony-app/config/packages/monolog.yaml b/examples/symfony-app/config/packages/monolog.yaml new file mode 100644 index 00000000..bb597086 --- /dev/null +++ b/examples/symfony-app/config/packages/monolog.yaml @@ -0,0 +1,55 @@ +monolog: + channels: + - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists + +when@dev: + monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + channels: ["!event"] + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine", "!console"] + +when@test: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!event"] + nested: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug + +when@prod: + monolog: + handlers: + main: + type: fingers_crossed + action_level: error + handler: nested + excluded_http_codes: [404, 405] + channels: ["!deprecation"] + buffer_size: 50 # How many messages should be saved? Prevent memory leaks + nested: + type: stream + path: php://stderr + level: debug + formatter: monolog.formatter.json + console: + type: console + process_psr_3_messages: false + channels: ["!event", "!doctrine"] + deprecation: + type: stream + channels: [deprecation] + path: php://stderr + formatter: monolog.formatter.json diff --git a/examples/symfony-app/config/packages/routing.yaml b/examples/symfony-app/config/packages/routing.yaml new file mode 100644 index 00000000..0f34f872 --- /dev/null +++ b/examples/symfony-app/config/packages/routing.yaml @@ -0,0 +1,10 @@ +framework: + router: + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/examples/symfony-app/config/preload.php b/examples/symfony-app/config/preload.php new file mode 100644 index 00000000..5ebcdb21 --- /dev/null +++ b/examples/symfony-app/config/preload.php @@ -0,0 +1,5 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|Param|null>|Param|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: InstanceofType, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: list|null, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: false + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: true|Param, // Default: true + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: list, + * events_to_dispatch?: list|null, + * places?: list, + * }>, + * transitions?: list, + * to?: list, + * weight?: int|Param, // Default: 1 + * metadata?: array, + * }>, + * metadata?: array, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: false + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: false + * paths?: array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: false + * fallbacks?: list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * static_method?: list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * auto_mapping?: array, + * }>, + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: false + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: array, + * named_serializers?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: false + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: false + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: false + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null + * pools?: array, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: false + * }, + * lock?: bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: false + * resources?: array>, + * }, + * semaphore?: bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: false + * routing?: array, + * }>, + * serializer?: array{ + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * retry_strategy?: string|array{ + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: list, + * default_bus?: scalar|Param|null, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: false + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: false + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|Param|null, + * recipients?: list, + * allowed_recipients?: list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: false + * limiters?: array, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: false + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: list, + * drop_elements?: list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: list, + * allowed_link_hosts?: list|null, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: list, + * allowed_media_hosts?: list|null, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: list, + * without_attribute_sanitizers?: list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * }, + * } + * @psalm-type MonologConfig = array{ + * use_microseconds?: scalar|Param|null, // Default: true + * channels?: list, + * handlers?: array, + * }>, + * accepted_levels?: list, + * min_level?: scalar|Param|null, // Default: "DEBUG" + * max_level?: scalar|Param|null, // Default: "EMERGENCY" + * buffer_size?: scalar|Param|null, // Default: 0 + * flush_on_overflow?: bool|Param, // Default: false + * handler?: scalar|Param|null, + * url?: scalar|Param|null, + * exchange?: scalar|Param|null, + * exchange_name?: scalar|Param|null, // Default: "log" + * channel?: scalar|Param|null, // Default: null + * bot_name?: scalar|Param|null, // Default: "Monolog" + * use_attachment?: scalar|Param|null, // Default: true + * use_short_attachment?: scalar|Param|null, // Default: false + * include_extra?: scalar|Param|null, // Default: false + * icon_emoji?: scalar|Param|null, // Default: null + * webhook_url?: scalar|Param|null, + * exclude_fields?: list, + * token?: scalar|Param|null, + * region?: scalar|Param|null, + * source?: scalar|Param|null, + * use_ssl?: bool|Param, // Default: true + * user?: mixed, + * title?: scalar|Param|null, // Default: null + * host?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 514 + * config?: list, + * members?: list, + * connection_string?: scalar|Param|null, + * timeout?: scalar|Param|null, + * time?: scalar|Param|null, // Default: 60 + * deduplication_level?: scalar|Param|null, // Default: 400 + * store?: scalar|Param|null, // Default: null + * connection_timeout?: scalar|Param|null, + * persistent?: bool|Param, + * message_type?: scalar|Param|null, // Default: 0 + * parse_mode?: scalar|Param|null, // Default: null + * disable_webpage_preview?: bool|Param|null, // Default: null + * disable_notification?: bool|Param|null, // Default: null + * split_long_messages?: bool|Param, // Default: false + * delay_between_messages?: bool|Param, // Default: false + * topic?: int|Param, // Default: null + * factor?: int|Param, // Default: 1 + * tags?: list, + * console_formatter_options?: mixed, // Default: [] + * formatter?: scalar|Param|null, + * nested?: bool|Param, // Default: false + * publisher?: string|array{ + * id?: scalar|Param|null, + * hostname?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 12201 + * chunk_size?: scalar|Param|null, // Default: 1420 + * encoder?: "json"|"compressed_json"|Param, + * }, + * mongodb?: string|array{ + * id?: scalar|Param|null, // ID of a MongoDB\Client service + * uri?: scalar|Param|null, + * username?: scalar|Param|null, + * password?: scalar|Param|null, + * database?: scalar|Param|null, // Default: "monolog" + * collection?: scalar|Param|null, // Default: "logs" + * }, + * elasticsearch?: string|array{ + * id?: scalar|Param|null, + * hosts?: list, + * host?: scalar|Param|null, + * port?: scalar|Param|null, // Default: 9200 + * transport?: scalar|Param|null, // Default: "Http" + * user?: scalar|Param|null, // Default: null + * password?: scalar|Param|null, // Default: null + * }, + * index?: scalar|Param|null, // Default: "monolog" + * document_type?: scalar|Param|null, // Default: "logs" + * ignore_error?: scalar|Param|null, // Default: false + * redis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * password?: scalar|Param|null, // Default: null + * port?: scalar|Param|null, // Default: 6379 + * database?: scalar|Param|null, // Default: 0 + * key_name?: scalar|Param|null, // Default: "monolog_redis" + * }, + * predis?: string|array{ + * id?: scalar|Param|null, + * host?: scalar|Param|null, + * }, + * from_email?: scalar|Param|null, + * to_email?: list, + * subject?: scalar|Param|null, + * content_type?: scalar|Param|null, // Default: null + * headers?: list, + * mailer?: scalar|Param|null, // Default: null + * email_prototype?: string|array{ + * id?: scalar|Param|null, + * method?: scalar|Param|null, // Default: null + * }, + * verbosity_levels?: array{ + * VERBOSITY_QUIET?: scalar|Param|null, // Default: "ERROR" + * VERBOSITY_NORMAL?: scalar|Param|null, // Default: "WARNING" + * VERBOSITY_VERBOSE?: scalar|Param|null, // Default: "NOTICE" + * VERBOSITY_VERY_VERBOSE?: scalar|Param|null, // Default: "INFO" + * VERBOSITY_DEBUG?: scalar|Param|null, // Default: "DEBUG" + * }, + * channels?: string|array{ + * type?: scalar|Param|null, + * elements?: list, + * }, + * }>, + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * monolog?: MonologConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * monolog?: MonologConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * monolog?: MonologConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * monolog?: MonologConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/examples/symfony-app/config/routes.yaml b/examples/symfony-app/config/routes.yaml new file mode 100644 index 00000000..cef258cd --- /dev/null +++ b/examples/symfony-app/config/routes.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json + +# This file is the entry point to configure the routes of your app. +# Methods with the #[Route] attribute are automatically imported. +# See also https://symfony.com/doc/current/routing.html + +# To list all registered routes, run the following command: +# bin/console debug:router + +controllers: + resource: routing.controllers diff --git a/examples/symfony-app/config/routes/framework.yaml b/examples/symfony-app/config/routes/framework.yaml new file mode 100644 index 00000000..bc1feace --- /dev/null +++ b/examples/symfony-app/config/routes/framework.yaml @@ -0,0 +1,4 @@ +when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.php' + prefix: /_error diff --git a/examples/symfony-app/config/services.yaml b/examples/symfony-app/config/services.yaml new file mode 100644 index 00000000..79b8ce2c --- /dev/null +++ b/examples/symfony-app/config/services.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json + +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. +# See also https://symfony.com/doc/current/service_container/import.html + +# Put parameters here that don't need to change on each machine where the app is deployed +# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration +parameters: + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + + # add more service definitions when explicit configuration is needed + # please note that last definitions always *replace* previous ones diff --git a/examples/symfony-app/public/index.php b/examples/symfony-app/public/index.php new file mode 100644 index 00000000..9a9dc82b --- /dev/null +++ b/examples/symfony-app/public/index.php @@ -0,0 +1,12 @@ +getManifestItems(); + $values = $state->all(); + + $result = []; + foreach ($items as $key => $schema) { + $value = $values[$key] ?? null; + $result[$key] = [ + 'type' => $schema['type'] ?? 'string', + 'required' => $schema['required'] ?? false, + 'sensitive' => $schema['sensitive'] ?? false, + 'value' => $state->isSensitive($key) ? '[REDACTED]' : $value, + ]; + } + + return new JsonResponse($result); + } + + #[Route('/varlock/log-test', name: 'varlock_log_test')] + public function logTest(): JsonResponse + { + $dbPassword = $_ENV['DB_PASSWORD'] ?? '(not set)'; + + // This will be caught by the Monolog processor and redacted + error_log("Connecting to database with password: {$dbPassword}"); + + return new JsonResponse([ + 'message' => 'Log entry written. Check var/log/dev.log — the password should be redacted in Monolog output.', + ]); + } +} diff --git a/examples/symfony-app/src/Kernel.php b/examples/symfony-app/src/Kernel.php new file mode 100644 index 00000000..779cd1f2 --- /dev/null +++ b/examples/symfony-app/src/Kernel.php @@ -0,0 +1,11 @@ + **TL;DR:** This PoC proves that `varlock compile` can produce a JSON manifest +> that PHP frameworks consume at boot time - no Node.js runtime, no `exec()`, +> no sidecar. Secrets are resolved from external APIs (1Password, Vault, etc.) +> and never stored in `.env` files. This makes codebases safe for AI agents +> that can read the filesystem but cannot reach a secrets API. + +--- + +## Table of contents + +1. [Why PHP needs a different approach](#why-php-needs-a-different-approach) +2. [How .env works in PHP (background for JS folks)](#how-env-works-in-php) +3. [The compiled manifest approach](#the-compiled-manifest-approach) +4. [Package structure](#package-structure) +5. [How the bootstrap works](#how-the-bootstrap-works) +6. [Secret resolution](#secret-resolution) +7. [The AI agent safety argument](#the-ai-agent-safety-argument) +8. [Running the demo](#running-the-demo) +9. [What `varlock compile` would need to generate](#what-varlock-compile-would-need-to-generate) +10. [Design decisions and trade-offs](#design-decisions-and-trade-offs) +11. [Extending to other languages](#extending-to-other-languages) + +--- + +## Why PHP needs a different approach + +In the JS ecosystem, varlock hooks into the module loader - it intercepts +`process.env` access at the language level. PHP has no equivalent. There is no +module loader to hook into, no way to intercept `$_ENV` reads, and shelling out +to a Node.js binary on every request is a non-starter for performance. + +But PHP applications **do** have a well-defined boot sequence with a clear +moment where env vars are loaded. If we can inject values into `$_ENV` at the +right point in that sequence, every call to `env('DB_PASSWORD')` downstream +sees our values - no framework patches, no monkey-patching. + +That is what this PoC does. The JS side (`varlock compile`) produces a static +JSON manifest. The PHP side reads it once at boot and injects values into the +environment. The two halves never run simultaneously. + +--- + +## How .env works in PHP + +This section exists because `.env` handling is fundamentally different between +Node.js and PHP, and these differences shape every design decision in this PoC. + +### The three places env vars live + +PHP has three independent places where environment variables can be read: + +```php +$_ENV['DB_PASSWORD'] // superglobal array (populated at PHP startup) +$_SERVER['DB_PASSWORD'] // superglobal array (populated by web server / CLI) +getenv('DB_PASSWORD') // reads from the C-level environ (libc) +``` + +To reliably set an env var that every library will see, you must write to all +three: + +```php +$_ENV[$key] = $value; +$_SERVER[$key] = $value; +putenv("$key=$value"); +``` + +This is exactly what our `VarlockBootstrap::setEnv()` does. + +### Laravel's boot sequence + +Laravel boots in a strict order. Understanding this order is critical because +**varlock must inject values after step 1 but before step 2**: + +``` +1. LoadEnvironmentVariables - reads .env via vlucas/phpdotenv, populates $_ENV +2. LoadConfiguration - requires config/*.php files, each calls env('APP_KEY') +3. RegisterProviders - runs service providers (too late for env injection) +4. BootProviders - calls boot() on each provider +``` + +The `env()` helper in step 2 reads from `$_ENV`. If `APP_KEY` is empty in +`$_ENV` at that point, `config('app.key')` will be empty for the entire +request - even if a service provider later sets it. + +**Our hook point:** + +```php +// bootstrap/app.php +$app->afterBootstrapping(LoadEnvironmentVariables::class, function () use ($app) { + \Varlock\Laravel\VarlockBootstrap::load($app->basePath()); +}); +``` + +This callback fires after Dotenv has parsed `.env` (step 1) but before config +files are loaded (step 2). At this point `.env` values are in `$_ENV`, and +our bootstrap can see them, decide which ones are empty, resolve the missing +secrets from an external API, and write them back into `$_ENV` before +`config/app.php` ever calls `env('APP_KEY')`. + +**Key detail - immutable Dotenv:** Laravel uses `Dotenv::createImmutable()`, +which means Dotenv will **not** overwrite values that already exist in `$_ENV`. +This is a feature for us: if we wrote values into `$_ENV` before Dotenv ran, +Dotenv would skip them. But since we run *after* Dotenv, we're the ones +overwriting Dotenv's empty values with resolved secrets. Both directions work. + +**Config caching:** In production, Laravel can cache all config into a single +PHP file (`php artisan config:cache`). When this cache exists, `.env` is never +read and `env()` calls return `null`. Our bootstrap detects this +(`bootstrap/cache/config.php` exists) and skips resolution entirely - the +cached config already has the baked-in values from when the cache was generated. + +### Symfony's boot sequence + +Symfony is simpler but has its own quirk: + +``` +1. public/index.php - requires autoload_runtime.php +2. autoload_runtime.php - loads .env via Symfony\Dotenv, boots the runtime +3. Runtime calls the closure - receives $context with APP_ENV, APP_DEBUG +4. Kernel boots - compiles the container, loads bundles +``` + +The `.env` is loaded inside `autoload_runtime.php` (step 2), and the closure +in `index.php` receives the loaded context (step 3). Our hook: + +```php +// public/index.php +return function (array $context) { + \Varlock\Symfony\VarlockBootstrap::load(dirname(__DIR__)); + return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); +}; +``` + +By the time our bootstrap runs, Dotenv has already populated `$_ENV`. We +resolve missing secrets and write them back. The kernel then boots with +all values available. + +**Key difference from Laravel:** Symfony does not coerce types at the env +level. Instead, it uses "env processors" in config files: +`%env(bool:APP_DEBUG)%`, `%env(int:DB_PORT)%`. So our Symfony bootstrap +stores all values as strings and skips the type coercion step. + +### Comparison table + +| Aspect | Laravel | Symfony | Node.js (for reference) | +|---|---|---|---| +| .env library | vlucas/phpdotenv | symfony/dotenv | dotenv (or built-in) | +| Where env lives | `$_ENV`, `$_SERVER`, `getenv()` | `$_ENV`, `$_SERVER`, `getenv()` | `process.env` | +| Config reads env at | Boot time (config files loaded once) | Compile time (container compiled once) | Runtime (every access) | +| Type coercion | Manual (`(bool) env('DEBUG')`) | Env processors (`%env(bool:DEBUG)%`) | Manual | +| Config caching | `php artisan config:cache` | `bin/console cache:warmup` | N/A | +| Our hook point | `afterBootstrapping(LoadEnv)` | Inside runtime closure | Module loader | + +--- + +## The compiled manifest approach + +The manifest is the contract between the JS tooling and the PHP runtime: + +``` + varlock compile (JS/Node) manifest.json PHP bootstrap + ┌──────────────────────┐ ┌──────────────────┐ ┌─────────────────┐ + │ Reads .env.spec │──────>│ Schema rules │─────>│ Validates env │ + │ Reads plugin configs │ │ Default values │ │ Resolves secrets│ + │ Zero secrets in file │ │ Resolve configs │ │ Coerces types │ + └──────────────────────┘ │ Sensitive flags │ │ Redacts logs │ + └──────────────────┘ └─────────────────┘ + Safe to commit + Safe for AI to read +``` + +Example manifest (committed to repo, contains zero secrets): + +```json +{ + "items": { + "APP_KEY": { + "type": "string", + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://localhost:9777/secrets", + "field": "APP_KEY" + } + }, + "DB_HOST": { + "type": "string", + "required": true, + "sensitive": false, + "default": "127.0.0.1" + } + } +} +``` + +The `resolve` block tells the PHP SDK *how* to get the secret, not *what* the +secret is. The `endpoint` could be 1Password Connect, HashiCorp Vault, AWS +Secrets Manager, or any HTTP API. + +--- + +## Package structure + +``` +packages/sdks/ +├── php-core/ # varlock/php-core - framework-agnostic +│ ├── composer.json +│ └── src/ +│ ├── ManifestLoader.php # Reads .varlock/manifest.json +│ ├── Validator.php # Checks required fields, validates types +│ ├── TypeCoercer.php # "3306" → 3306, "true" → true +│ ├── VarlockState.php # Static singleton: holds values + sensitive map +│ ├── RedactionHelper.php # Replaces secret values with [REDACTED] +│ ├── SecretResolverFactory.php # Registry that dispatches to plugin resolvers +│ ├── Contracts/ +│ │ └── SecretResolverInterface.php +│ ├── Resolvers/ +│ │ ├── HttpSecretResolver.php # Calls any HTTP secrets API +│ │ ├── EnvSecretResolver.php # Reads from process env (Docker/K8s) +│ │ ├── CallbackSecretResolver.php # Wraps a user-provided closure +│ │ └── ChainSecretResolver.php # Tries resolvers in order (fallback) +│ └── Exceptions/ +│ ├── VarlockValidationException.php +│ └── ManifestNotFoundException.php +│ +├── php-laravel/ # varlock/laravel - Laravel integration +│ ├── composer.json +│ └── src/ +│ ├── VarlockBootstrap.php # The main entry point, called from bootstrap/app.php +│ ├── VarlockServiceProvider.php # Registers artisan command + Monolog processor +│ ├── Console/ +│ │ └── StatusCommand.php # `php artisan varlock:status` +│ └── Logging/ +│ └── RedactSensitiveProcessor.php # Monolog processor +│ +└── php-symfony/ # varlock/symfony-bundle - Symfony integration + ├── composer.json + └── src/ + ├── VarlockBootstrap.php # Called from public/index.php + ├── VarlockBundle.php # Registers Monolog processor via DI + └── Logging/ + └── RedactSensitiveProcessor.php # Monolog processor +``` + +**Why three packages?** + +- `php-core` has zero framework dependencies - just PHP 8.2. Could be used + with Slim, Laminas, WordPress, or any custom app. +- `php-laravel` and `php-symfony` are thin wrappers that know where to hook + into each framework's boot sequence. + +This mirrors varlock's JS architecture: core logic in one place, integrations +for Next.js / Vite / Astro as separate packages. + +--- + +## How the bootstrap works + +Every request (HTTP or CLI) goes through this flow once: + +``` +1. Framework loads .env file into $_ENV + └─ At this point: DB_PASSWORD="" (empty - no secret in .env) + +2. VarlockBootstrap::load() runs + ├─ Reads .varlock/manifest.json + ├─ For each item in manifest: + │ ├─ Check $_ENV - is there already a value? → use it + │ ├─ Is there a "resolve" block? → call secret API + │ └─ Is there a "default"? → use it + ├─ Validate all values (required? correct type?) + ├─ Coerce types (Laravel only - Symfony skips this) + ├─ Write resolved values into $_ENV / $_SERVER / putenv() + └─ Store sensitive value map in VarlockState singleton + +3. Framework loads config files (config/app.php etc.) + └─ env('DB_PASSWORD') now returns the resolved secret + +4. Application runs normally + └─ Any log message containing a secret value → [REDACTED] by Monolog processor +``` + +The key insight: step 2 happens in a ~5ms window between "dotenv loaded" and +"config parsed". The secret never exists on the filesystem. It lives in +process memory for the duration of the request. + +--- + +## Secret resolution + +### Resolution priority + +For each item in the manifest, the bootstrap tries three sources in order: + +``` +1. Existing env var - from .env, process env, or Docker/K8s injection +2. Resolve block - calls external API (1Password, Vault, HTTP, etc.) +3. Default value - from the manifest itself +``` + +If all three fail for a `required: true` item, the app crashes at boot with a +clear error message listing all missing vars. This is intentional - fail fast, +fail loud. + +### Built-in resolvers + +**HttpSecretResolver** - The workhorse. Calls any HTTP endpoint, parses JSON, +extracts a field by dot-notation path. Caches responses per endpoint so +multiple secrets from the same API (e.g. batch endpoint) make only one HTTP +request. + +```json +{ + "resolve": { + "plugin": "1password", + "endpoint": "http://op-connect:8080/v1/vaults/abc/items/def", + "field": "fields.password.value", + "headers": { "Authorization": "Bearer {{OP_CONNECT_TOKEN}}" } + } +} +``` + +Note the `{{OP_CONNECT_TOKEN}}` - header values can reference process +env vars (injected by the orchestrator, not from `.env`). This way the +*token to authenticate with the secret manager* also never touches the +filesystem. + +**EnvSecretResolver** - For Docker/Kubernetes environments where the +orchestrator injects secrets as process env vars. Reads from +`VARLOCK_SECRET_DB_PASSWORD` (prefixed to avoid collision with the actual +`DB_PASSWORD` key). + +**CallbackSecretResolver** - Escape hatch. Wrap any PHP closure: + +```php +SecretResolverFactory::register('custom', new CallbackSecretResolver( + fn(array $config) => MyVault::getSecret($config['key']) +)); +``` + +**ChainSecretResolver** - Try multiple resolvers in order: + +```php +SecretResolverFactory::register('1password', new ChainSecretResolver([ + new HttpSecretResolver(), // try Connect API first + new EnvSecretResolver(), // fall back to orchestrator env +])); +``` + +### Auto-registration + +The bootstrap scans the manifest for `resolve` blocks. If a plugin has an +`endpoint` field and no resolver is manually registered, the +`HttpSecretResolver` is automatically registered for that plugin. This means +**zero configuration** for HTTP-based secret managers - just put the endpoint +in the manifest. + +--- + +## The AI agent safety argument + +This is the motivating use case for the whole approach. + +### The problem + +AI coding agents (Claude, Copilot, Cursor, etc.) operate by reading files in +the project directory. A `.env` file containing `DB_PASSWORD=hunter2` is +trivially readable by any agent with file system access. The secret is now in +the agent's context window and potentially in API logs, training data, or +error reports. + +### The current state + +``` +# .env (today - secrets on filesystem) +APP_KEY=base64:9McFRwiu6WCB21XjXdjz2b3njJCsnsVS6Qmz9FbdDGk= +DB_PASSWORD=prod-db-P@ssw0rd!-2026-rotated +STRIPE_SECRET=sk_live_abc123... +``` + +Every file-reading tool call can leak these. + +### With varlock + +``` +# .env (with varlock - no secrets on filesystem) +APP_KEY= +DB_PASSWORD= +STRIPE_SECRET= +``` + +```json +// .varlock/manifest.json (safe to read - zero secrets) +{ + "items": { + "DB_PASSWORD": { + "required": true, + "sensitive": true, + "resolve": { + "plugin": "1password", + "endpoint": "http://op-connect:8080/v1/vaults/abc/items/def", + "field": "DB_PASSWORD" + } + } + } +} +``` + +The AI agent can read both files. It learns that `DB_PASSWORD` is required, +that it's a string, that it's sensitive, and that it comes from 1Password. +It learns *everything it needs to work with the codebase* except the actual +secret value. The secret only exists in process memory at runtime, resolved +via an HTTP call the agent cannot make. + +### Defense in depth with log redaction + +Even if application code accidentally logs a secret: + +```php +Log::info("Connecting with password: {$dbPassword}"); +``` + +The Monolog processor intercepts this and writes: + +``` +[2026-03-14] local.INFO: Connecting with password: [REDACTED] +``` + +The `VarlockState` singleton knows which values are sensitive (from the +manifest's `sensitive: true` flag) and the `RedactSensitiveProcessor` +replaces any occurrence of those values in log messages. This means even log +files are safe for AI agents to read. + +--- + +## Running the demo + +Prerequisites: PHP 8.2+, Composer. + +### 1. Start the mock secret server + +```bash +php examples/mock-secret-server.php & +# Serves secrets on http://localhost:9777 +# In production, this would be 1Password Connect, Vault, etc. +``` + +### 2. Laravel + +```bash +cd examples/laravel-app + +# See that .env has NO secrets: +grep -E "^(APP_KEY|DB_PASSWORD)" .env +# APP_KEY= +# DB_PASSWORD= + +# Yet the app boots and resolves them from the mock API: +php artisan varlock:status +# +-------------+---------+----------+-----------+--------------+ +# | Key | Type | Required | Sensitive | Value | +# | APP_KEY | string | Yes | Yes | [REDACTED] | +# | DB_PASSWORD | string | Yes | Yes | [REDACTED] | +# | DB_HOST | string | Yes | No | 127.0.0.1 | +# +-------------+---------+----------+-----------+--------------+ + +# Web server works too: +php artisan serve --port=8077 & +curl localhost:8077/varlock/status # JSON with redacted secrets +curl localhost:8077/varlock/log-test # check storage/logs/laravel.log - password is [REDACTED] +``` + +### 3. Symfony + +```bash +cd examples/symfony-app +php -S localhost:8078 -t public & +curl localhost:8078/varlock/status # JSON with redacted secrets +``` + +### 4. Kill the mock server when done + +```bash +kill %1 # or: lsof -ti:9777 | xargs kill +``` + +--- + +## What `varlock compile` would need to generate + +For PHP support, the `compile` command would write `.varlock/manifest.json` +with this schema: + +```typescript +interface Manifest { + version: string; + generatedAt: string; // ISO timestamp + items: Record; +} + +interface ManifestItem { + type: 'string' | 'boolean' | 'number' | 'integer' | 'email' | 'url'; + required: boolean; + sensitive: boolean; + default?: string; // always a string - PHP SDK handles coercion + resolve?: ResolveConfig; // only for items backed by a secret manager +} + +interface ResolveConfig { + plugin: string; // matches a varlock plugin name (e.g. "1password") + endpoint: string; // HTTP URL for the secrets API + field?: string; // dot-notation path into the JSON response + headers?: Record; // {{ENV_VAR}} placeholders expanded at runtime +} +``` + +This is the *only* artifact the JS side needs to produce. The PHP SDK does +everything else. + +### Mapping from existing varlock concepts + +| Varlock JS concept | Manifest field | Notes | +|---|---|---| +| Item type (`VarlockDataType`) | `type` | Simplified to primitive types | +| `@required` decorator | `required` | | +| `@sensitive` decorator | `sensitive` | Drives redaction | +| Default value | `default` | Serialized as string | +| `op()` / `awsSecret()` resolver | `resolve.plugin` + `resolve.endpoint` | The PHP SDK doesn't import the JS plugin - it just calls the HTTP endpoint | +| `@initOp()` config | `resolve.headers` | Auth tokens etc. | + +--- + +## Design decisions and trade-offs + +### Why a static manifest instead of running the JS engine? + +- **Performance:** PHP-FPM processes are short-lived. Spawning Node on every + request adds 50-200ms. The manifest is read in <1ms. +- **No runtime dependency:** Production PHP servers don't need Node.js + installed. +- **Simplicity:** JSON is the universal data format. Any language can read it. + +Trade-off: the manifest must be regenerated when the schema changes +(`varlock compile` in CI or as a git hook). + +### Why HTTP-based secret resolution instead of CLI tools? + +1Password's `op` CLI, AWS CLI, etc. require shelling out (`exec()`), which is +slow, blocked on many hosting platforms, and a security red flag. HTTP calls +via `file_get_contents()` work everywhere and complete in milliseconds on a +local network. + +In production, you'd run 1Password Connect as a sidecar or use the managed +service endpoint. The PHP SDK just makes a GET request. + +### Why three env writes (`$_ENV`, `$_SERVER`, `putenv`)? + +Different PHP libraries read env from different sources. Laravel's `env()` +checks all three. Symfony's `Dotenv` writes to all three. Some legacy code +uses `getenv()` directly. Writing to all three ensures compatibility. + +### Why does VarlockState use a static singleton? + +PHP has no application-level dependency injection that survives across the +boot sequence (before the framework container exists). A static singleton +ensures the Monolog processor (registered later via the service provider) +can access the sensitive values map without constructor injection. + +### Why skip resolution when Laravel config is cached? + +When `php artisan config:cache` runs, it evaluates all `env()` calls once and +dumps the results to `bootstrap/cache/config.php`. After that, `env()` returns +`null` - the cached values are used directly. Calling the secret API would +be wasteful and would fail if the API isn't reachable during deployment. + +--- + +## Extending to other languages + +The manifest approach is designed to be language-agnostic. Here's what each +new language SDK needs: + +1. **Manifest reader** - Parse JSON. Every language has this. +2. **Env injector** - Write values into the language's env mechanism + (`process.env`, `os.environ`, `$_ENV`, `System.getenv()`). +3. **Boot hook** - Find the right moment in the framework's lifecycle. +4. **Secret resolver** - HTTP client to call the secrets API. +5. **Log redactor** - Hook into the logging framework. + +| Language | Framework | Boot hook | Env mechanism | Logging | +|---|---|---|---|---| +| PHP | Laravel | `afterBootstrapping()` | `$_ENV`/`putenv()` | Monolog processor | +| PHP | Symfony | Runtime closure | `$_ENV`/`putenv()` | Monolog processor | +| Python | Django | `settings.py` or `AppConfig.ready()` | `os.environ` | `logging.Filter` | +| Python | FastAPI | `lifespan` event | `os.environ` | `logging.Filter` | +| Ruby | Rails | `config/initializers/` | `ENV` | `ActiveSupport::Logger` | +| Go | Any | `init()` or `main()` | `os.Setenv()` | `slog.Handler` | +| Rust | Any | `main()` before framework | `std::env::set_var()` | `tracing` layer | + +The hard part is always finding the right boot hook. The manifest format and +resolution logic are identical across languages. + +--- + +## File inventory + +New files added by this PoC (relative to repo root): + +``` +packages/sdks/php-core/ 14 files Core library, no framework deps +packages/sdks/php-laravel/ 5 files Laravel service provider + bootstrap +packages/sdks/php-symfony/ 4 files Symfony bundle + bootstrap +examples/.varlock/manifest.json 1 file Shared example manifest +examples/mock-secret-server.php 1 file Demo HTTP secrets API +examples/laravel-app/ ~60 files Laravel 11 skeleton + varlock integration +examples/symfony-app/ ~30 files Symfony 8 skeleton + varlock integration +``` + +No existing files were modified except `.gitignore` (added PHP entries). diff --git a/packages/sdks/php-core/composer.json b/packages/sdks/php-core/composer.json new file mode 100644 index 00000000..db87bb1e --- /dev/null +++ b/packages/sdks/php-core/composer.json @@ -0,0 +1,16 @@ +{ + "name": "varlock/php-core", + "description": "Core PHP SDK for Varlock - manifest-based env validation, type coercion, and secret resolution", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2" + }, + "autoload": { + "psr-4": { + "Varlock\\Core\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/sdks/php-core/src/Contracts/SecretResolverInterface.php b/packages/sdks/php-core/src/Contracts/SecretResolverInterface.php new file mode 100644 index 00000000..150ab18a --- /dev/null +++ b/packages/sdks/php-core/src/Contracts/SecretResolverInterface.php @@ -0,0 +1,17 @@ + '1password', 'vault' => '...', 'item' => '...']) + * @return string The resolved secret value + */ + public function resolve(array $config): string; +} diff --git a/packages/sdks/php-core/src/Exceptions/ManifestNotFoundException.php b/packages/sdks/php-core/src/Exceptions/ManifestNotFoundException.php new file mode 100644 index 00000000..ee40e0ed --- /dev/null +++ b/packages/sdks/php-core/src/Exceptions/ManifestNotFoundException.php @@ -0,0 +1,9 @@ + */ + private array $validationErrors; + + public function __construct(array $errors) + { + $this->validationErrors = $errors; + + $message = "Varlock validation failed:\n"; + foreach ($errors as $key => $error) { + $message .= " - {$error}\n"; + } + + parent::__construct($message); + } + + /** @return array */ + public function getValidationErrors(): array + { + return $this->validationErrors; + } +} diff --git a/packages/sdks/php-core/src/ManifestLoader.php b/packages/sdks/php-core/src/ManifestLoader.php new file mode 100644 index 00000000..3d526eb2 --- /dev/null +++ b/packages/sdks/php-core/src/ManifestLoader.php @@ -0,0 +1,30 @@ + strlen($b) - strlen($a)); + + return str_replace($sensitiveValues, self::REDACTED, $input); + } +} diff --git a/packages/sdks/php-core/src/Resolvers/CallbackSecretResolver.php b/packages/sdks/php-core/src/Resolvers/CallbackSecretResolver.php new file mode 100644 index 00000000..bcbbadbe --- /dev/null +++ b/packages/sdks/php-core/src/Resolvers/CallbackSecretResolver.php @@ -0,0 +1,32 @@ + MyVault::get($config['item'], $config['field']) + * )); + */ +class CallbackSecretResolver implements SecretResolverInterface +{ + /** @var \Closure(array): string */ + private \Closure $callback; + + public function __construct(\Closure $callback) + { + $this->callback = $callback; + } + + public function resolve(array $config): string + { + return ($this->callback)($config); + } +} diff --git a/packages/sdks/php-core/src/Resolvers/ChainSecretResolver.php b/packages/sdks/php-core/src/Resolvers/ChainSecretResolver.php new file mode 100644 index 00000000..51933a14 --- /dev/null +++ b/packages/sdks/php-core/src/Resolvers/ChainSecretResolver.php @@ -0,0 +1,48 @@ +resolvers = $resolvers; + } + + public function resolve(array $config): string + { + $lastException = null; + + foreach ($this->resolvers as $resolver) { + try { + return $resolver->resolve($config); + } catch (\Throwable $e) { + $lastException = $e; + } + } + + throw new \RuntimeException( + 'ChainSecretResolver: all resolvers failed. Last error: ' . $lastException?->getMessage(), + 0, + $lastException, + ); + } +} diff --git a/packages/sdks/php-core/src/Resolvers/EnvSecretResolver.php b/packages/sdks/php-core/src/Resolvers/EnvSecretResolver.php new file mode 100644 index 00000000..a782ea10 --- /dev/null +++ b/packages/sdks/php-core/src/Resolvers/EnvSecretResolver.php @@ -0,0 +1,63 @@ +prefix = $prefix; + } + + public function resolve(array $config): string + { + // Allow explicit env var name override + $envVar = $config['envVar'] ?? null; + + if ($envVar === null) { + // Derive from the manifest key: DB_PASSWORD -> VARLOCK_SECRET_DB_PASSWORD + $key = $config['key'] ?? null; + if ($key === null) { + throw new \RuntimeException( + 'EnvSecretResolver: resolve config missing both "envVar" and "key".' + ); + } + $envVar = $this->prefix . $key; + } + + // Read from real process env only (getenv), NOT from $_ENV/$_SERVER + // which may be populated from .env files + $value = getenv($envVar); + + if ($value === false || $value === '') { + throw new \RuntimeException( + "EnvSecretResolver: process env var '{$envVar}' is not set. " + . 'Ensure it is injected by your orchestrator (Docker/K8s/CI).' + ); + } + + return $value; + } +} diff --git a/packages/sdks/php-core/src/Resolvers/HttpSecretResolver.php b/packages/sdks/php-core/src/Resolvers/HttpSecretResolver.php new file mode 100644 index 00000000..d0606947 --- /dev/null +++ b/packages/sdks/php-core/src/Resolvers/HttpSecretResolver.php @@ -0,0 +1,118 @@ + Response cache keyed by endpoint+headers hash */ + private array $responseCache = []; + + public function resolve(array $config): string + { + $endpoint = $config['endpoint'] ?? null; + if ($endpoint === null) { + throw new \RuntimeException('HttpSecretResolver: missing "endpoint" in resolve config.'); + } + + $headers = []; + foreach ($config['headers'] ?? [] as $name => $value) { + // Expand {{ENV_VAR}} references in header values from real env vars + $expanded = preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) { + $envVal = getenv($matches[1]); + if ($envVal === false) { + throw new \RuntimeException( + "HttpSecretResolver: header references env var '{$matches[1]}' which is not set." + ); + } + return $envVal; + }, $value); + $headers[] = "{$name}: {$expanded}"; + } + + // Cache key: same endpoint + same headers = same response + $cacheKey = md5($endpoint . '|' . implode('|', $headers)); + $response = $this->responseCache[$cacheKey] ?? null; + + if ($response === null) { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", $headers), + 'timeout' => 10, + 'ignore_errors' => true, + ], + ]); + + $response = @file_get_contents($endpoint, false, $context); + if ($response === false) { + throw new \RuntimeException( + "HttpSecretResolver: failed to fetch secret from '{$endpoint}'." + ); + } + + $this->responseCache[$cacheKey] = $response; + } + + // If a field is specified, parse JSON and extract it + $field = $config['field'] ?? null; + if ($field !== null) { + $data = json_decode($response, true); + if (!is_array($data)) { + throw new \RuntimeException( + "HttpSecretResolver: response from '{$endpoint}' is not valid JSON." + ); + } + return $this->extractField($data, $field, $endpoint); + } + + return trim($response); + } + + /** + * Extract a value using dot-notation path (e.g. "fields.password.value"). + */ + private function extractField(array $data, string $path, string $endpoint): string + { + $segments = explode('.', $path); + $current = $data; + + foreach ($segments as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + throw new \RuntimeException( + "HttpSecretResolver: field '{$path}' not found in response from '{$endpoint}'." + ); + } + $current = $current[$segment]; + } + + if (!is_scalar($current)) { + throw new \RuntimeException( + "HttpSecretResolver: field '{$path}' resolved to a non-scalar value." + ); + } + + return (string) $current; + } +} diff --git a/packages/sdks/php-core/src/SecretResolverFactory.php b/packages/sdks/php-core/src/SecretResolverFactory.php new file mode 100644 index 00000000..24994d90 --- /dev/null +++ b/packages/sdks/php-core/src/SecretResolverFactory.php @@ -0,0 +1,110 @@ + */ + private static array $resolvers = []; + + /** @var bool Whether built-in resolvers have been registered */ + private static bool $builtInsRegistered = false; + + /** + * Register a resolver for a given plugin name. + */ + public static function register(string $pluginName, SecretResolverInterface $resolver): void + { + self::$resolvers[$pluginName] = $resolver; + } + + /** + * Register built-in resolvers for plugins that have no custom resolver. + * + * Scans the manifest items and registers the HttpSecretResolver for any + * plugin that has an "endpoint" in its resolve config and isn't already + * registered. This means manifests with HTTP-based resolve configs + * work out of the box without manual registration. + */ + public static function registerBuiltIns(array $items): void + { + if (self::$builtInsRegistered) { + return; + } + self::$builtInsRegistered = true; + + $httpResolver = null; + + foreach ($items as $schema) { + $resolve = $schema['resolve'] ?? null; + if ($resolve === null) { + continue; + } + + $plugin = $resolve['plugin'] ?? null; + if ($plugin === null || self::has($plugin)) { + continue; + } + + // If the resolve config has an endpoint, register the HTTP resolver + if (isset($resolve['endpoint'])) { + $httpResolver ??= new HttpSecretResolver(); + self::register($plugin, $httpResolver); + } + } + } + + /** + * Resolve a value using the appropriate plugin resolver. + * + * @param array $resolveConfig The 'resolve' block from a manifest item + * @param string $key The manifest item key (passed through to the resolver) + * @return string The resolved secret value + * @throws \RuntimeException If no resolver is registered for the plugin + */ + public static function resolve(array $resolveConfig, string $key = ''): string + { + $plugin = $resolveConfig['plugin'] ?? null; + + if ($plugin === null) { + throw new \RuntimeException('Manifest resolve config missing "plugin" key.'); + } + + $resolver = self::$resolvers[$plugin] ?? null; + + if ($resolver === null) { + throw new \RuntimeException( + "No secret resolver registered for plugin '{$plugin}'. " + . 'Register one via SecretResolverFactory::register() or ensure ' + . 'the resolve config includes an "endpoint" for automatic HTTP resolution.' + ); + } + + // Inject the manifest key so resolvers can use it (e.g. EnvSecretResolver) + $resolveConfig['key'] = $key; + + return $resolver->resolve($resolveConfig); + } + + /** + * Check if a resolver is registered for the given plugin. + */ + public static function has(string $pluginName): bool + { + return isset(self::$resolvers[$pluginName]); + } + + /** + * Clear all registered resolvers (useful for testing). + */ + public static function reset(): void + { + self::$resolvers = []; + self::$builtInsRegistered = false; + } +} diff --git a/packages/sdks/php-core/src/TypeCoercer.php b/packages/sdks/php-core/src/TypeCoercer.php new file mode 100644 index 00000000..b702f6d8 --- /dev/null +++ b/packages/sdks/php-core/src/TypeCoercer.php @@ -0,0 +1,56 @@ + self::coerceBool($value), + 'number' => self::coerceNumber($value), + 'integer', 'int' => (int) $value, + default => $value, + }; + } + + /** + * Coerce all values in a map based on their manifest types. + * + * @param array $values Resolved string values + * @param array $items Manifest items with type info + * @return array Coerced values + */ + public static function coerceAll(array $values, array $items): array + { + $result = $values; + + foreach ($items as $key => $schema) { + if (!isset($result[$key]) || $result[$key] === '') { + continue; + } + $type = $schema['type'] ?? 'string'; + $result[$key] = self::coerce((string) $result[$key], $type); + } + + return $result; + } + + private static function coerceBool(string $value): bool + { + return in_array(strtolower($value), ['true', '1', 'yes'], true); + } + + private static function coerceNumber(string $value): int|float + { + if (str_contains($value, '.')) { + return (float) $value; + } + return (int) $value; + } +} diff --git a/packages/sdks/php-core/src/Validator.php b/packages/sdks/php-core/src/Validator.php new file mode 100644 index 00000000..f56b7436 --- /dev/null +++ b/packages/sdks/php-core/src/Validator.php @@ -0,0 +1,75 @@ + $values Resolved env values (key => value) + * @param array $items Manifest items (key => schema) + * @return array Errors keyed by env var name + */ + public static function validate(array $values, array $items): array + { + $errors = []; + + foreach ($items as $key => $schema) { + $value = $values[$key] ?? null; + $required = $schema['required'] ?? false; + + if ($required && ($value === null || $value === '')) { + $errors[$key] = "Required env var '{$key}' is missing or empty."; + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $type = $schema['type'] ?? null; + if ($type !== null) { + $typeError = self::validateType($key, (string) $value, $type); + if ($typeError !== null) { + $errors[$key] = $typeError; + } + } + } + + return $errors; + } + + /** + * Validate and throw if errors exist. + */ + public static function validateOrThrow(array $values, array $items): void + { + $errors = self::validate($values, $items); + if (!empty($errors)) { + throw new VarlockValidationException($errors); + } + } + + private static function validateType(string $key, string $value, string $type): ?string + { + return match ($type) { + 'string' => null, + 'number', 'integer', 'int' => is_numeric($value) ? null : "Env var '{$key}' must be numeric, got '{$value}'.", + 'boolean', 'bool' => in_array(strtolower($value), ['true', 'false', '1', '0', 'yes', 'no', ''], true) + ? null + : "Env var '{$key}' must be a boolean, got '{$value}'.", + 'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false + ? null + : "Env var '{$key}' must be a valid email, got '{$value}'.", + 'url' => filter_var($value, FILTER_VALIDATE_URL) !== false + ? null + : "Env var '{$key}' must be a valid URL, got '{$value}'.", + default => null, // Unknown types pass validation + }; + } +} diff --git a/packages/sdks/php-core/src/VarlockState.php b/packages/sdks/php-core/src/VarlockState.php new file mode 100644 index 00000000..df52c338 --- /dev/null +++ b/packages/sdks/php-core/src/VarlockState.php @@ -0,0 +1,86 @@ + All resolved config values */ + private array $values = []; + + /** @var array Keys that are marked sensitive */ + private array $sensitiveKeys = []; + + /** @var array Raw manifest items */ + private array $manifestItems = []; + + private function __construct() {} + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public static function reset(): void + { + self::$instance = null; + } + + public function initialize(array $values, array $sensitiveKeys, array $manifestItems): void + { + $this->values = $values; + $this->sensitiveKeys = $sensitiveKeys; + $this->manifestItems = $manifestItems; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function all(): array + { + return $this->values; + } + + public function isSensitive(string $key): bool + { + return isset($this->sensitiveKeys[$key]); + } + + /** + * Get all sensitive values (for redaction matching). + * + * @return string[] + */ + public function getSensitiveValues(): array + { + $sensitiveValues = []; + foreach ($this->sensitiveKeys as $key => $_) { + $value = $this->values[$key] ?? null; + if ($value !== null && $value !== '' && is_string($value)) { + $sensitiveValues[] = $value; + } + } + return $sensitiveValues; + } + + public function getManifestItems(): array + { + return $this->manifestItems; + } + + /** + * Redact sensitive values from a string. + */ + public function redact(string $input): string + { + return RedactionHelper::redact($input, $this->getSensitiveValues()); + } +} diff --git a/packages/sdks/php-laravel/composer.json b/packages/sdks/php-laravel/composer.json new file mode 100644 index 00000000..be85959d --- /dev/null +++ b/packages/sdks/php-laravel/composer.json @@ -0,0 +1,25 @@ +{ + "name": "varlock/laravel", + "description": "Laravel integration for Varlock - env validation, secret resolution, and log redaction", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2", + "varlock/php-core": "*", + "illuminate/support": "^11.0|^12.0" + }, + "autoload": { + "psr-4": { + "Varlock\\Laravel\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Varlock\\Laravel\\VarlockServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/sdks/php-laravel/src/Console/StatusCommand.php b/packages/sdks/php-laravel/src/Console/StatusCommand.php new file mode 100644 index 00000000..b36333be --- /dev/null +++ b/packages/sdks/php-laravel/src/Console/StatusCommand.php @@ -0,0 +1,47 @@ +getManifestItems(); + $values = $state->all(); + + if (empty($items)) { + $this->warn('No Varlock manifest loaded. Is VarlockBootstrap::load() called in bootstrap/app.php?'); + return 1; + } + + $rows = []; + foreach ($items as $key => $schema) { + $value = $values[$key] ?? ''; + $display = $state->isSensitive($key) && $value !== '' + ? '[REDACTED]' + : (string) $value; + + $rows[] = [ + $key, + $schema['type'] ?? 'string', + ($schema['required'] ?? false) ? 'Yes' : 'No', + ($schema['sensitive'] ?? false) ? 'Yes' : 'No', + $display, + ]; + } + + $this->table(['Key', 'Type', 'Required', 'Sensitive', 'Value'], $rows); + + return 0; + } +} diff --git a/packages/sdks/php-laravel/src/Logging/RedactSensitiveProcessor.php b/packages/sdks/php-laravel/src/Logging/RedactSensitiveProcessor.php new file mode 100644 index 00000000..6d9cf631 --- /dev/null +++ b/packages/sdks/php-laravel/src/Logging/RedactSensitiveProcessor.php @@ -0,0 +1,26 @@ +getSensitiveValues(); + + if (empty($sensitiveValues)) { + return $record; + } + + return $record->with( + message: $state->redact($record->message), + ); + } +} diff --git a/packages/sdks/php-laravel/src/VarlockBootstrap.php b/packages/sdks/php-laravel/src/VarlockBootstrap.php new file mode 100644 index 00000000..50fba2bc --- /dev/null +++ b/packages/sdks/php-laravel/src/VarlockBootstrap.php @@ -0,0 +1,161 @@ + $schema) { + if (!empty($schema['sensitive'])) { + $sensitiveKeys[$key] = true; + } + } + VarlockState::getInstance()->initialize([], $sensitiveKeys, $items); + return; + } + + // Auto-register built-in resolvers (e.g. HTTP) for plugins found in manifest + SecretResolverFactory::registerBuiltIns($items); + + $values = []; + $sensitiveKeys = []; + $resolveErrors = []; + + foreach ($items as $key => $schema) { + // Priority: existing env > secret manager > manifest default + $value = self::getExistingEnv($key); + + if ($value === null && isset($schema['resolve'])) { + try { + $value = SecretResolverFactory::resolve($schema['resolve'], $key); + } catch (\Throwable $e) { + $resolveErrors[$key] = $e->getMessage(); + } + } + + if ($value === null && array_key_exists('default', $schema)) { + $value = (string) $schema['default']; + } + + if ($value !== null) { + $values[$key] = $value; + } + + if (!empty($schema['sensitive'])) { + $sensitiveKeys[$key] = true; + } + } + + // Report resolution errors before validation so the developer sees + // both "couldn't reach secret server" AND "these vars are missing". + if (!empty($resolveErrors)) { + $msg = "Varlock: failed to resolve secrets from external source:\n"; + foreach ($resolveErrors as $key => $error) { + $msg .= " - {$key}: {$error}\n"; + } + fwrite(STDERR, "\n\033[33m{$msg}\033[0m\n"); + } + + // Validate before coercion (validation works on string values) + Validator::validateOrThrow($values, $items); + + // Coerce types for PHP-native access + $coerced = TypeCoercer::coerceAll($values, $items); + + // Populate env so Laravel's env() helper and Dotenv see these values. + // We write the string versions to env (env vars are always strings), + // and store coerced versions in VarlockState. + foreach ($values as $key => $value) { + self::setEnv($key, (string) $value); + } + + // Initialize state singleton + VarlockState::getInstance()->initialize($coerced, $sensitiveKeys, $items); + } + + /** + * Output a fatal error and exit. Used when we're too early in the boot + * sequence for Laravel's exception handler to work. + */ + private static function abort(\Throwable $e): never + { + $message = $e->getMessage(); + + if (PHP_SAPI === 'cli') { + fwrite(STDERR, "\n\033[31mVarlock bootstrap failed:\033[0m\n{$message}\n\n"); + } else { + // Minimal HTML for browser display during development + http_response_code(500); + echo "

Varlock bootstrap failed

" . htmlspecialchars($message) . "
"; + } + + exit(1); + } + + private static function getExistingEnv(string $key): ?string + { + // Check in order: $_ENV, $_SERVER, getenv() + if (isset($_ENV[$key]) && $_ENV[$key] !== '') { + return $_ENV[$key]; + } + if (isset($_SERVER[$key]) && $_SERVER[$key] !== '' && !is_array($_SERVER[$key])) { + return (string) $_SERVER[$key]; + } + $val = getenv($key); + return ($val !== false && $val !== '') ? $val : null; + } + + private static function setEnv(string $key, string $value): void + { + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + putenv("{$key}={$value}"); + } +} diff --git a/packages/sdks/php-laravel/src/VarlockServiceProvider.php b/packages/sdks/php-laravel/src/VarlockServiceProvider.php new file mode 100644 index 00000000..ff46ea8c --- /dev/null +++ b/packages/sdks/php-laravel/src/VarlockServiceProvider.php @@ -0,0 +1,40 @@ +getManifestItems() === []) { + VarlockBootstrap::load($this->app->basePath()); + } + + $this->app->singleton(VarlockState::class, fn() => VarlockState::getInstance()); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([StatusCommand::class]); + } + + // Register Monolog processor for log redaction on the default channel's handler + $logger = $this->app->make('log'); + $monolog = $logger->driver()->getLogger(); + if ($monolog instanceof \Monolog\Logger) { + $monolog->pushProcessor(new RedactSensitiveProcessor()); + } + } +} diff --git a/packages/sdks/php-symfony/composer.json b/packages/sdks/php-symfony/composer.json new file mode 100644 index 00000000..13c651dd --- /dev/null +++ b/packages/sdks/php-symfony/composer.json @@ -0,0 +1,18 @@ +{ + "name": "varlock/symfony-bundle", + "description": "Symfony bundle for Varlock - env validation, secret resolution, and log redaction", + "type": "symfony-bundle", + "license": "MIT", + "require": { + "php": "^8.2", + "varlock/php-core": "*", + "symfony/framework-bundle": "^7.0|^8.0" + }, + "autoload": { + "psr-4": { + "Varlock\\Symfony\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/sdks/php-symfony/src/Logging/RedactSensitiveProcessor.php b/packages/sdks/php-symfony/src/Logging/RedactSensitiveProcessor.php new file mode 100644 index 00000000..7a56b849 --- /dev/null +++ b/packages/sdks/php-symfony/src/Logging/RedactSensitiveProcessor.php @@ -0,0 +1,26 @@ +getSensitiveValues(); + + if (empty($sensitiveValues)) { + return $record; + } + + return $record->with( + message: $state->redact($record->message), + ); + } +} diff --git a/packages/sdks/php-symfony/src/VarlockBootstrap.php b/packages/sdks/php-symfony/src/VarlockBootstrap.php new file mode 100644 index 00000000..443b2b37 --- /dev/null +++ b/packages/sdks/php-symfony/src/VarlockBootstrap.php @@ -0,0 +1,85 @@ + $schema) { + $value = self::getExistingEnv($key); + + if ($value === null && isset($schema['resolve'])) { + $value = SecretResolverFactory::resolve($schema['resolve'], $key); + } + + if ($value === null && array_key_exists('default', $schema)) { + $value = (string) $schema['default']; + } + + if ($value !== null) { + $values[$key] = $value; + } + + if (!empty($schema['sensitive'])) { + $sensitiveKeys[$key] = true; + } + } + + // Validate (string values only — Symfony processors handle casting) + Validator::validateOrThrow($values, $items); + + // Populate env (strings only for Symfony compatibility) + foreach ($values as $key => $value) { + self::setEnv($key, (string) $value); + } + + // Initialize state singleton (stores strings — Symfony handles types) + VarlockState::getInstance()->initialize($values, $sensitiveKeys, $items); + } + + private static function getExistingEnv(string $key): ?string + { + if (isset($_ENV[$key]) && $_ENV[$key] !== '') { + return $_ENV[$key]; + } + if (isset($_SERVER[$key]) && $_SERVER[$key] !== '' && !is_array($_SERVER[$key])) { + return (string) $_SERVER[$key]; + } + $val = getenv($key); + return ($val !== false && $val !== '') ? $val : null; + } + + private static function setEnv(string $key, string $value): void + { + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + putenv("{$key}={$value}"); + } +} diff --git a/packages/sdks/php-symfony/src/VarlockBundle.php b/packages/sdks/php-symfony/src/VarlockBundle.php new file mode 100644 index 00000000..acb1ccd8 --- /dev/null +++ b/packages/sdks/php-symfony/src/VarlockBundle.php @@ -0,0 +1,20 @@ +services() + ->set('varlock.log_processor', RedactSensitiveProcessor::class) + ->tag('monolog.processor'); + } +}