diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile
deleted file mode 100644
index a3a7de4..0000000
--- a/.docker/php/Dockerfile
+++ /dev/null
@@ -1,25 +0,0 @@
-ARG PHP_VERSION=8.3
-
-FROM php:${PHP_VERSION}-alpine
-
-# Install system dependencies
-RUN apk update && apk add --no-cache \
- $PHPIZE_DEPS \
- linux-headers \
- zlib-dev \
- libmemcached-dev \
- cyrus-sasl-dev
-
-RUN pecl install xdebug redis memcached \
- && docker-php-ext-enable xdebug redis memcached
-
-# Copy custom PHP configuration
-COPY .docker/php/kariricode-php.ini /usr/local/etc/php/conf.d/
-
-# Instalação do Composer
-RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
-
-RUN apk del --purge $PHPIZE_DEPS && rm -rf /var/cache/apk/*
-
-# Mantém o contêiner ativo sem fazer nada
-CMD tail -f /dev/null
diff --git a/.docker/php/kariricode-php.ini b/.docker/php/kariricode-php.ini
deleted file mode 100644
index 9e90446..0000000
--- a/.docker/php/kariricode-php.ini
+++ /dev/null
@@ -1,14 +0,0 @@
-[PHP]
-memory_limit = 256M
-upload_max_filesize = 50M
-post_max_size = 50M
-date.timezone = America/Sao_Paulo
-
-[Xdebug]
-; zend_extension=xdebug.so
-xdebug.mode=debug
-xdebug.start_with_request=yes
-xdebug.client_host=host.docker.internal
-xdebug.client_port=9003
-xdebug.log=/tmp/xdebug.log
-xdebug.idekey=VSCODE
diff --git a/.env.example b/.env.example
deleted file mode 100644
index fba5ae4..0000000
--- a/.env.example
+++ /dev/null
@@ -1,30 +0,0 @@
-KARIRI_APP_ENV=develop
-KARIRI_APP_NAME=KaririCode
-
-KARIRI_PHP_VERSION=8.3
-KARIRI_PHP_PORT=9003
-
-KARIRI_APP_DEBUG=false
-KARIRI_APP_URL=https://kariricode.com
-
-
-KARIRI_DB_CONNECTION=mysql
-KARIRI_DB_HOST=127.0.0.1
-KARIRI_DB_PORT=3306
-KARIRI_DB_DATABASE=kariricode
-KARIRI_DB_USERNAME=root
-KARIRI_DB_PASSWORD=secret
-
-KARIRI_CACHE_DRIVER=redis
-KARIRI_SESSION_LIFETIME=120
-
-KARIRI_MAIL_MAILER=smtp
-KARIRI_MAIL_HOST=mailhog
-KARIRI_MAIL_PORT=1025
-KARIRI_MAIL_USERNAME=null
-KARIRI_MAIL_PASSWORD=null
-KARIRI_MAIL_ENCRYPTION=null
-KARIRI_MAIL_FROM_ADDRESS=null
-KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}"
-
-KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5e5baad..2c36f2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -64,3 +64,6 @@ tests/lista_de_arquivos_test.php
lista_de_arquivos.txt
lista_de_arquivos_tests.txt
add_static_to_providers.php
+
+# KaririCode Devkit — generated configs and build artifacts
+.kcode/
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
deleted file mode 100644
index c3a51bb..0000000
--- a/.php-cs-fixer.php
+++ /dev/null
@@ -1,69 +0,0 @@
-in(__DIR__ . '/src')
- ->in(__DIR__ . '/tests')
- ->exclude('var')
- ->exclude('config')
- ->exclude('vendor');
-
-return (new PhpCsFixer\Config())
- ->setParallelConfig(new PhpCsFixer\Runner\Parallel\ParallelConfig(4, 20))
- ->setRules([
- '@PSR12' => true,
- '@Symfony' => true,
- 'full_opening_tag' => false,
- 'phpdoc_var_without_name' => false,
- 'phpdoc_to_comment' => false,
- 'array_syntax' => ['syntax' => 'short'],
- 'concat_space' => ['spacing' => 'one'],
- 'binary_operator_spaces' => [
- 'default' => 'single_space',
- 'operators' => [
- '=' => 'single_space',
- '=>' => 'single_space',
- ],
- ],
- 'blank_line_before_statement' => [
- 'statements' => ['return']
- ],
- 'cast_spaces' => ['space' => 'single'],
- 'class_attributes_separation' => [
- 'elements' => [
- 'const' => 'none',
- 'method' => 'one',
- 'property' => 'none'
- ]
- ],
- 'declare_equal_normalize' => ['space' => 'none'],
- 'function_typehint_space' => true,
- 'lowercase_cast' => true,
- 'no_unused_imports' => true,
- 'not_operator_with_successor_space' => true,
- 'ordered_imports' => true,
- 'phpdoc_align' => ['align' => 'left'],
- 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var', 'link' => 'see']],
- 'phpdoc_order' => true,
- 'phpdoc_scalar' => true,
- 'single_quote' => true,
- 'standardize_not_equals' => true,
- 'trailing_comma_in_multiline' => ['elements' => ['arrays']],
- 'trim_array_spaces' => true,
- 'space_after_semicolon' => true,
- 'no_spaces_inside_parenthesis' => true,
- 'no_whitespace_before_comma_in_array' => true,
- 'whitespace_after_comma_in_array' => true,
- 'visibility_required' => ['elements' => ['const', 'method', 'property']],
- 'multiline_whitespace_before_semicolons' => [
- 'strategy' => 'no_multi_line',
- ],
- 'method_chaining_indentation' => true,
- 'class_definition' => [
- 'single_item_single_line' => false,
- 'multi_line_extends_each_single_line' => true,
- ],
- 'not_operator_with_successor_space' => false
- ])
- ->setRiskyAllowed(true)
- ->setFinder($finder)
- ->setUsingCache(false);
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 38f7f80..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "[php]": {
- "editor.defaultFormatter": "junstyle.php-cs-fixer"
- },
- "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer",
- "php-cs-fixer.onsave": true,
- "php-cs-fixer.rules": "@PSR12",
- "php-cs-fixer.config": ".php_cs.dist",
- "php-cs-fixer.formatHtml": true
-}
diff --git a/Makefile b/Makefile
deleted file mode 100644
index f6efe50..0000000
--- a/Makefile
+++ /dev/null
@@ -1,174 +0,0 @@
-# Initial configurations
-PHP_SERVICE := kariricode-dotenv
-DC := docker-compose
-
-# Command to execute commands inside the PHP container
-EXEC_PHP := $(DC) exec -T php
-
-# Icons
-CHECK_MARK := ✅
-WARNING := ⚠️
-INFO := ℹ️
-
-# Colors
-RED := \033[0;31m
-GREEN := \033[0;32m
-YELLOW := \033[1;33m
-NC := \033[0m # No Color
-
-# Check if Docker is installed
-CHECK_DOCKER := @command -v docker > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker is not installed. Aborting.${NC}"; exit 1; }
-# Check if Docker Compose is installed
-CHECK_DOCKER_COMPOSE := @command -v docker-compose > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker Compose is not installed. Aborting.${NC}"; exit 1; }
-# Function to check if the container is running
-CHECK_CONTAINER_RUNNING := @docker ps | grep $(PHP_SERVICE) > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} The container $(PHP_SERVICE) is not running. Run 'make up' to start it.${NC}"; exit 1; }
-# Check if the .env file exists
-CHECK_ENV := @test -f .env || { echo >&2 "${YELLOW}${WARNING} .env file not found. Run 'make setup-env' to configure.${NC}"; exit 1; }
-
-## setup-env: Copy .env.example to .env if the latter does not exist
-setup-env:
- @test -f .env || (cp .env.example .env && echo "${GREEN}${CHECK_MARK} .env file created successfully from .env.example${NC}")
-
-check-environment:
- @echo "${GREEN}${INFO} Checking Docker, Docker Compose, and .env file...${NC}"
- $(CHECK_DOCKER)
- $(CHECK_DOCKER_COMPOSE)
- $(CHECK_ENV)
-
-check-container-running:
- $(CHECK_CONTAINER_RUNNING)
-
-## up: Start all services in the background
-up: check-environment
- @echo "${GREEN}${INFO} Starting services...${NC}"
- @$(DC) up -d
- @echo "${GREEN}${CHECK_MARK} Services are up!${NC}"
-
-## down: Stop and remove all containers
-down: check-environment
- @echo "${YELLOW}${INFO} Stopping and removing services...${NC}"
- @$(DC) down
- @echo "${GREEN}${CHECK_MARK} Services stopped and removed!${NC}"
-
-## build: Build Docker images
-build: check-environment
- @echo "${YELLOW}${INFO} Building services...${NC}"
- @$(DC) build
- @echo "${GREEN}${CHECK_MARK} Services built!${NC}"
-
-## logs: Show container logs
-logs: check-environment
- @echo "${YELLOW}${INFO} Container logs:${NC}"
- @$(DC) logs
-
-## re-build: Rebuild and restart containers
-re-build: check-environment
- @echo "${YELLOW}${INFO} Stopping and removing current services...${NC}"
- @$(DC) down
- @echo "${GREEN}${INFO} Rebuilding services...${NC}"
- @$(DC) build
- @echo "${GREEN}${INFO} Restarting services...${NC}"
- @$(DC) up -d
- @echo "${GREEN}${CHECK_MARK} Services rebuilt and restarted successfully!${NC}"
- @$(DC) logs
-
-## shell: Access the shell of the PHP container
-shell: check-environment check-container-running
- @echo "${GREEN}${INFO} Accessing the shell of the PHP container...${NC}"
- @$(DC) exec php sh
-
-## composer-install: Install Composer dependencies. Use make composer-install [PKG="[vendor/package [version]]"] [DEV="--dev"]
-composer-install: check-environment check-container-running
- @echo "${GREEN}${INFO} Installing Composer dependencies...${NC}"
- @if [ -z "$(PKG)" ]; then \
- $(EXEC_PHP) composer install; \
- else \
- $(EXEC_PHP) composer require $(PKG) $(DEV); \
- fi
- @echo "${GREEN}${CHECK_MARK} Composer operation completed!${NC}"
-
-## composer-remove: Remove Composer dependencies. Usage: make composer-remove PKG="vendor/package"
-composer-remove: check-environment check-container-running
- @if [ -z "$(PKG)" ]; then \
- echo "${RED}${WARNING} You must specify a package to remove. Usage: make composer-remove PKG=\"vendor/package\"${NC}"; \
- else \
- $(EXEC_PHP) composer remove $(PKG); \
- echo "${GREEN}${CHECK_MARK} Package $(PKG) removed successfully!${NC}"; \
- fi
-
-## composer-update: Update Composer dependencies
-composer-update: check-environment check-container-running
- @echo "${GREEN}${INFO} Updating Composer dependencies...${NC}"
- $(EXEC_PHP) composer update
- @echo "${GREEN}${CHECK_MARK} Dependencies updated!${NC}"
-
-## test: Run tests
-test: check-environment check-container-running
- @echo "${GREEN}${INFO} Running tests...${NC}"
- $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests
- @echo "${GREEN}${CHECK_MARK} Tests completed!${NC}"
-
-## test-file: Run tests on a specific class. Usage: make test-file FILE=[file]
-test-file: check-environment check-container-running
- @echo "${GREEN}${INFO} Running test for class $(FILE)...${NC}"
- $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests/$(FILE)
- @echo "${GREEN}${CHECK_MARK} Test for class $(FILE) completed!${NC}"
-
-## coverage: Run test coverage with visual formatting
-coverage: check-environment check-container-running
- @echo "${GREEN}${INFO} Analyzing test coverage...${NC}"
- XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-text --colors=always tests | ccze -A
-
-## coverage-html: Run test coverage and generate HTML report
-coverage-html: check-environment check-container-running
- @echo "${GREEN}${INFO} Analyzing test coverage and generating HTML report...${NC}"
- XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-html ./coverage-report-html tests
- @echo "${GREEN}${INFO} Test coverage report generated in ./coverage-report-html${NC}"
-
-## run-script: Run a PHP script. Usage: make run-script SCRIPT="path/to/script.php"
-run-script: check-environment check-container-running
- @echo "${GREEN}${INFO} Running script: $(SCRIPT)...${NC}"
- $(EXEC_PHP) php $(SCRIPT)
- @echo "${GREEN}${CHECK_MARK} Script executed!${NC}"
-
-## cs-check: Run PHP_CodeSniffer to check code style
-cs-check: check-environment check-container-running
- @echo "${GREEN}${INFO} Checking code style...${NC}"
- $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix --dry-run --diff
- @echo "${GREEN}${CHECK_MARK} Code style check completed!${NC}"
-
-## cs-fix: Run PHP CS Fixer to fix code style
-cs-fix: check-environment check-container-running
- @echo "${GREEN}${INFO} Fixing code style with PHP CS Fixer...${NC}"
- $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix
- @echo "${GREEN}${CHECK_MARK} Code style fixed!${NC}"
-
-## security-check: Check for security vulnerabilities in dependencies
-security-check: check-environment check-container-running
- @echo "${GREEN}${INFO} Checking for security vulnerabilities with Security Checker...${NC}"
- $(EXEC_PHP) ./vendor/bin/security-checker security:check
- @echo "${GREEN}${CHECK_MARK} Security check completed!${NC}"
-
-## quality: Run all quality commands
-quality: check-environment check-container-running cs-check test security-check
- @echo "${GREEN}${CHECK_MARK} All quality commands executed!${NC}"
-
-## help: Show initial setup steps and available commands
-help:
- @echo "${GREEN}Initial setup steps for configuring the project:${NC}"
- @echo "1. ${YELLOW}Initial environment setup:${NC}"
- @echo " ${GREEN}${CHECK_MARK} Copy the environment file:${NC} make setup-env"
- @echo " ${GREEN}${CHECK_MARK} Start the Docker containers:${NC} make up"
- @echo " ${GREEN}${CHECK_MARK} Install Composer dependencies:${NC} make composer-install"
- @echo "2. ${YELLOW}Development:${NC}"
- @echo " ${GREEN}${CHECK_MARK} Access the PHP container shell:${NC} make shell"
- @echo " ${GREEN}${CHECK_MARK} Run a PHP script:${NC} make run-script SCRIPT=\"script_name.php\""
- @echo " ${GREEN}${CHECK_MARK} Run the tests:${NC} make test"
- @echo "3. ${YELLOW}Maintenance:${NC}"
- @echo " ${GREEN}${CHECK_MARK} Update Composer dependencies:${NC} make composer-update"
- @echo " ${GREEN}${CHECK_MARK} Clear the application cache:${NC} make cache-clear"
- @echo " ${RED}${WARNING} Stop and remove all Docker containers:${NC} make down"
- @echo "\n${GREEN}Available commands:${NC}"
- @sed -n 's/^##//p' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ": "}; {printf "${YELLOW}%-30s${NC} %s\n", $$1, $$2}'
-
-.PHONY: setup-env up down build logs re-build shell composer-install composer-remove composer-update test test-file coverage coverage-html run-script cs-check cs-fix security-check quality help
diff --git a/README.md b/README.md
index 85202f8..105fe1f 100644
--- a/README.md
+++ b/README.md
@@ -1,84 +1,116 @@
-# KaririCode Framework: Dotenv Component
+# KaririCode Dotenv
-[](README.md) [](README.pt-br.md)
+
-  
+[](https://www.php.net/)
+[](LICENSE)
+[](https://phpstan.org/)
+[](https://kariricode.org)
+[](composer.json)
+[](https://kariricode.org)
+[](https://kariricode.org)
-A robust and flexible environment variable management component for the KaririCode Framework, providing advanced features for handling .env files in PHP applications.
+**The only PHP dotenv with AES-256-GCM encryption, OPcache caching, fluent validation DSL,
+environment-aware cascade loading, and auto type casting — zero dependencies, PHP 8.4+.**
-## Features
+[Installation](#installation) · [Quick Start](#quick-start) · [Features](#features) · [Validation DSL](#validation-dsl) · [Encryption](#encryption) · [Architecture](#architecture)
-- Parse and load environment variables from .env files
-- Support for variable interpolation
-- **Automatic type detection and casting**
- - Detects and converts common types (string, integer, float, boolean, array, JSON)
- - Preserves data types for more accurate usage in your application
-- **Customizable type system**
- - Extensible with custom type detectors and casters
- - Fine-grained control over how your environment variables are processed
-- Strict mode for variable name validation
-- Easy access to environment variables through a global helper function
-- Support for complex data structures (arrays and JSON) in environment variables
+
-## Installation
+---
-To install the KaririCode Dotenv component in your project, run the following command:
+## The Problem
-```bash
-composer require kariricode/dotenv
+Every PHP project reinvents the same wheel:
+
+```php
+// No type safety — you get raw strings everywhere
+$_ENV['DB_PORT'] // "5432" (string, not int)
+$_ENV['DEBUG'] // "true" (string, not bool)
+
+// No validation — missing vars discovered at runtime
+// No encryption — secrets sit as plaintext in .env files
+// No cascade — can't load .env.local over .env automatically
```
-## Usage
+## The Solution
-### Basic Usage
+```php
+$dotenv = new Dotenv(__DIR__);
+$dotenv->load();
-1. Create a `.env` file in your project's root directory:
+// Auto-typed
+env('DB_PORT'); // 5432 (int)
+env('DEBUG'); // true (bool)
+
+// Validated before service boot
+$dotenv->validate()
+ ->required('DB_HOST', 'DB_PORT')
+ ->isInteger('DB_PORT')->between(1, 65535)
+ ->url('APP_URL')
+ ->email('ADMIN_EMAIL')
+ ->assert();
+
+// Encrypted secrets — transparent decryption
+// SECRET=encrypted:base64data...
+$dotenv->get('SECRET'); // "my-actual-secret"
+```
-```env
-KARIRI_APP_ENV=develop
-KARIRI_APP_NAME=KaririCode
-KARIRI_PHP_VERSION=8.3
-KARIRI_PHP_PORT=9003
-KARIRI_APP_DEBUG=true
-KARIRI_APP_URL=https://kariricode.com
-KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}"
-KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}}
-KARIRI_ARRAY_CONFIG=["item1", "item2", "item with spaces"]
+---
+
+## Requirements
+
+| Requirement | Version |
+|---|---|
+| PHP | 8.4 or higher |
+| ext-openssl | Optional (required for encryption) |
+
+---
+
+## Installation
+
+```bash
+composer require kariricode/dotenv
```
-2. In your application's bootstrap file:
+---
+
+## Quick Start
+
+```bash
+# 1. Create your .env
+APP_ENV=production
+APP_URL=https://myapp.com
+DB_HOST=localhost
+DB_PORT=5432
+APP_DEBUG=false
+```
```php
load();
-// Now you can use the env() function to access your environment variables
-$appName = env('KARIRI_APP_NAME');
-$debug = env('KARIRI_APP_DEBUG');
-$jsonConfig = env('KARIRI_JSON_CONFIG');
-$arrayConfig = env('KARIRI_ARRAY_CONFIG');
+// Typed access via helper
+$port = env('DB_PORT'); // int: 5432
+$debug = env('APP_DEBUG'); // bool: false
+$host = env('DB_HOST'); // string: "localhost"
```
-### Type Detection and Casting
+---
-The KaririCode Dotenv component automatically detects and casts the following types:
+## Features
-- Strings
-- Integers
-- Floats
-- Booleans
-- Null values
-- Arrays
-- JSON objects
+### Auto Type Casting
-Example:
+All values are automatically cast to their native PHP type:
```env
STRING_VAR=Hello World
@@ -86,133 +118,293 @@ INT_VAR=42
FLOAT_VAR=3.14
BOOL_VAR=true
NULL_VAR=null
-ARRAY_VAR=["item1", "item2", "item3"]
JSON_VAR={"key": "value", "nested": {"subkey": "subvalue"}}
+ARRAY_VAR=["item1", "item2", "item3"]
+```
+
+```php
+env('STRING_VAR'); // string: "Hello World"
+env('INT_VAR'); // int: 42
+env('FLOAT_VAR'); // float: 3.14
+env('BOOL_VAR'); // bool: true
+env('NULL_VAR'); // null
+env('JSON_VAR'); // array: ["key" => "value", "nested" => [...]]
+env('ARRAY_VAR'); // array: ["item1", "item2", "item3"]
```
-When accessed using the `env()` function, these variables will be automatically cast to their appropriate PHP types:
+### Variable Interpolation
+
+```env
+APP_NAME=KaririCode
+GREETING="Welcome to ${APP_NAME}" # → "Welcome to KaririCode"
+HAS_REDIS=${REDIS_HOST:+yes} # "yes" if REDIS_HOST is set
+FALLBACK=${MISSING_VAR:-default-value} # "default-value" if unset
+```
+
+### Load Modes
```php
-$stringVar = env('STRING_VAR'); // string: "Hello World"
-$intVar = env('INT_VAR'); // integer: 42
-$floatVar = env('FLOAT_VAR'); // float: 3.14
-$boolVar = env('BOOL_VAR'); // boolean: true
-$nullVar = env('NULL_VAR'); // null
-$arrayVar = env('ARRAY_VAR'); // array: ["item1", "item2", "item3"]
-$jsonVar = env('JSON_VAR'); // array: ["key" => "value", "nested" => ["subkey" => "subvalue"]]
+use KaririCode\Dotenv\Enum\LoadMode;
+use KaririCode\Dotenv\ValueObject\DotenvConfiguration;
+
+// Immutable (default) — skip vars already in environment
+$config = new DotenvConfiguration(loadMode: LoadMode::Immutable);
+
+// SkipExisting — keep existing $_ENV values, skip .env values
+$config = new DotenvConfiguration(loadMode: LoadMode::SkipExisting);
+
+// Overwrite — .env always wins
+$config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
```
-This automatic typing ensures that you're working with the correct data types in your application, reducing type-related errors and improving overall code reliability.
+### Environment Cascade (`bootEnv`)
+
+Loads files in priority order, later files overriding earlier ones:
-### Advanced Usage
+```
+.env → .env.local → .env.{APP_ENV} → .env.{APP_ENV}.local
+```
+
+```php
+$dotenv = new Dotenv(__DIR__, new DotenvConfiguration(loadMode: LoadMode::Overwrite));
+$dotenv->bootEnv(); // reads APP_ENV from environment automatically
+$dotenv->bootEnv('staging'); // explicit environment
+```
-#### Custom Type Detectors
+> `.env.test.local` is **always skipped** when `APP_ENV=test` — ensuring reproducible test runs.
-Create custom type detectors to handle specific formats:
+### Multiple Files
```php
-use KaririCode\Dotenv\Type\Detector\AbstractTypeDetector;
+$dotenv = new Dotenv(__DIR__, $config, '.env', '.env.local');
+$dotenv->load();
+```
-class CustomDetector extends AbstractTypeDetector
-{
- public const PRIORITY = 100;
+### Allow / Deny Lists (glob patterns)
- public function detect(mixed $value): ?string
- {
- // Your detection logic here
- // Return the detected type as a string, or null if not detected
- }
-}
+```php
+// Only load DB_* variables
+$config = new DotenvConfiguration(allowList: ['DB_*']);
-$dotenv->addTypeDetector(new CustomDetector());
+// Load everything except SECRET*
+$config = new DotenvConfiguration(denyList: ['SECRET*']);
```
-#### Custom Type Casters
+---
+
+## Validation DSL
-Create custom type casters to handle specific data types:
+Fluent, chainable validation with **all errors collected before throwing** (no fail-fast):
```php
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+$dotenv->validate()
+ ->required('DB_HOST', 'DB_PORT', 'APP_ENV')
+ ->notEmpty('DB_HOST')
+ ->isInteger('DB_PORT')->between(1, 65535)
+ ->isBoolean('APP_DEBUG')
+ ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ ->url('APP_URL')
+ ->email('ADMIN_EMAIL')
+ ->matchesRegex('BUILD_SHA', '/^[a-f0-9]{40}$/')
+ ->ifPresent('REDIS_HOST')->notEmpty()
+ ->custom('DB_DSN', fn(string $v): bool => str_starts_with($v, 'pgsql:'))
+ ->assert(); // throws ValidationException with ALL failures at once
+```
-class CustomCaster implements TypeCaster
-{
- public function cast(mixed $value): mixed
- {
- // Your casting logic here
- }
-}
+### Schema-Based Validation (`.env.schema`)
-$dotenv->addTypeCaster('custom_type', new CustomCaster());
+```ini
+[DB_HOST]
+required = true
+notEmpty = true
+
+[DB_PORT]
+required = true
+type = integer
+min = 1
+max = 65535
+
+[APP_ENV]
+required = true
+allowed = local, staging, production
```
-## Development and Testing
+```php
+$dotenv->loadWithSchema('/path/to/.env.schema');
+```
-For development and testing purposes, this package uses Docker and Docker Compose to ensure consistency across different environments. A Makefile is provided for convenience.
+---
+
+## Encryption
-### Prerequisites
+AES-256-GCM authenticated encryption for secrets. Encrypted values use the `encrypted:` prefix and are **transparently decrypted** on load.
-- Docker
-- Docker Compose
-- Make (optional, but recommended for easier command execution)
+```php
+use KaririCode\Dotenv\Security\KeyPair;
+use KaririCode\Dotenv\Security\Encryptor;
-### Setup for Development
+// Generate a key pair (store the private key securely!)
+$keyPair = KeyPair::generate();
+$encryptor = new Encryptor($keyPair->privateKey);
-1. Clone the repository:
+// Encrypt a secret
+$encrypted = $encryptor->encrypt('my-secret-password');
+// → "encrypted:base64encodedpayload..."
+```
+
+```env
+# .env — commit this (value is opaque ciphertext)
+DB_PASSWORD=encrypted:aGVsbG8gd29ybGQ...
+```
+
+```php
+// Decryption happens transparently during load
+$config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey);
+$dotenv = new Dotenv(__DIR__, $config);
+$dotenv->load();
- ```bash
- git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git
- cd kariricode-dotenv
- ```
+$dotenv->get('DB_PASSWORD'); // "my-secret-password"
+```
-2. Set up the environment:
+---
- ```bash
- make setup-env
- ```
+## OPcache Caching
-3. Start the Docker containers:
+Compile parsed variables into an OPcache-friendly PHP file. Subsequent requests load from shared memory with **zero parsing cost**:
- ```bash
- make up
- ```
+```php
+$dotenv->load();
+$dotenv->dumpCache('/path/to/.env.cache.php');
-4. Install dependencies:
- ```bash
- make composer-install
- ```
+// Next request:
+$config = new DotenvConfiguration(cachePath: '/path/to/.env.cache.php');
+$dotenv = new Dotenv(__DIR__, $config);
+$dotenv->load(); // loaded from OPcache — no file I/O
+```
-### Available Make Commands
+---
-- `make up`: Start all services in the background
-- `make down`: Stop and remove all containers
-- `make build`: Build Docker images
-- `make shell`: Access the shell of the PHP container
-- `make test`: Run tests
-- `make coverage`: Run test coverage with visual formatting
-- `make cs-fix`: Run PHP CS Fixer to fix code style
-- `make quality`: Run all quality commands (cs-check, test, security-check)
+## Variable Processors
-For a full list of available commands, run:
+Transform values after parsing, with **glob pattern matching** for key selection:
-```bash
-make help
+```php
+use KaririCode\Dotenv\Processor\CsvToArrayProcessor;
+use KaririCode\Dotenv\Processor\UrlNormalizerProcessor;
+use KaririCode\Dotenv\Processor\TrimProcessor;
+use KaririCode\Dotenv\Processor\Base64DecodeProcessor;
+
+$dotenv->addProcessor('ALLOWED_IPS', new CsvToArrayProcessor()); // "a, b, c" → ["a","b","c"]
+$dotenv->addProcessor('*_URL', new UrlNormalizerProcessor()); // glob: all *_URL keys
+$dotenv->addProcessor('API_TOKEN', new Base64DecodeProcessor());
+$dotenv->addProcessor('DB_HOST', new TrimProcessor());
```
-## License
+---
+
+## Debug & Introspection
+
+```php
+$dotenv->load();
+
+// Source tracking — where did each variable come from?
+$debug = $dotenv->debug();
+// ['DB_HOST' => ['source' => '.env.local', 'type' => 'String', 'value' => 'localhost', 'overridden' => true]]
+
+// All loaded variables as EnvironmentVariable value objects
+$vars = $dotenv->variables();
+
+// Safe load — skip missing files instead of throwing
+$dotenv->safeLoad();
+```
+
+---
+
+## Architecture
+
+### Source layout
+
+```
+src/
+├── Cache/ OPcache-friendly PHP file cache (PhpFileCache)
+├── Contract/ Interfaces: TypeCaster · TypeDetector · ValidationRule · VariableProcessor
+├── Core/ DotenvParser — full .env syntax support
+├── Dotenv.php Main facade — load · validate · encrypt · cache · bootEnv
+├── Enum/ LoadMode · ValueType
+├── Exception/ DotenvException hierarchy (5 classes)
+├── Processor/ CsvToArray · UrlNormalizer · Trim · Base64Decode
+├── Schema/ SchemaParser — .env.schema declarative validation
+├── Security/ Encryptor (AES-256-GCM) · KeyPair
+├── Type/ TypeSystem + 6 Detectors + 6 Casters
+├── Validation/ EnvironmentValidator (fluent DSL) + 10 Rule classes
+├── ValueObject/ DotenvConfiguration (immutable) · EnvironmentVariable (immutable)
+└── env.php Global env() helper
+```
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+### Key design decisions
+
+| Decision | Rationale | ADR |
+|---|---|---|
+| Zero dependencies | No version conflicts, sub-ms boot | [ADR-001](docs/adr/ADR-001-zero-dependencies.md) |
+| Immutable configuration | Thread-safe, ARFA 1.3 compliant | [ADR-002](docs/adr/ADR-002-immutable-configuration.md) |
+| Pluggable type system | Extend without modifying framework code | [ADR-003](docs/adr/ADR-003-type-system.md) |
+| AES-256-GCM encryption | Authenticated encryption, nonce-per-value | [ADR-004](docs/adr/ADR-004-encryption-format.md) |
+| OPcache caching | Zero parse overhead in production | [ADR-005](docs/adr/ADR-005-opcache-cache.md) |
+| Fluent validation DSL | Collect all errors before throwing | [ADR-008](docs/adr/ADR-008-validation-strategy.md) |
+
+### Specifications
+
+| Spec | Covers |
+|---|---|
+| [SPEC-001](docs/spec/SPEC-001-env-syntax.md) | `.env` file syntax and parsing rules |
+| [SPEC-002](docs/spec/SPEC-002-type-system.md) | Type detection and casting |
+| [SPEC-003](docs/spec/SPEC-003-encryption.md) | Encryption format and key derivation |
+| [SPEC-004](docs/spec/SPEC-004-validation.md) | Validation DSL API |
+| [SPEC-005](docs/spec/SPEC-005-schema-format.md) | `.env.schema` format |
+| [SPEC-006](docs/spec/SPEC-006-processors.md) | Variable processor contract |
+| [SPEC-007](docs/spec/SPEC-007-cli.md) | CLI tooling |
-## Support and Community
+---
-- **Documentation**: [https://kariricode.org/docs/dotenv](https://kariricode.org/docs/dotenv)
-- **Issue Tracker**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues)
-- **Community**: [KaririCode Club Community](https://kariricode.club)
+## Project Stats
+
+| Metric | Value |
+|---|---|
+| PHP source files | 38 |
+| External runtime dependencies | 0 |
+| Test suite | 205 tests · 396 assertions |
+| PHPStan level | 9 |
+| PHP version | 8.4+ |
+| ARFA compliance | 1.3 |
+| Encryption | AES-256-GCM |
+| Type detection | 7 built-in types (extensible) |
+| Validation rules | 10 built-in rules (extensible) |
+| Variable processors | 4 built-in (extensible) |
-## Acknowledgments
+---
-- The KaririCode Framework team and contributors.
-- Inspired by other popular PHP Dotenv libraries.
+## Contributing
+
+```bash
+git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git
+cd kariricode-dotenv
+composer install
+kcode init
+kcode quality # Must pass before opening a PR
+```
---
-Built with ❤️ by the KaririCode team. Empowering developers to build more robust and flexible PHP applications.
+## License
+
+[MIT License](LICENSE) © [Walmir Silva](mailto:community@kariricode.org)
+
+---
+
+
+
+Part of the **[KaririCode Framework](https://kariricode.org)** ecosystem.
+
+[kariricode.org](https://kariricode.org) · [GitHub](https://github.com/KaririCode-Framework/kariricode-dotenv) · [Packagist](https://packagist.org/packages/kariricode/dotenv) · [Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues)
+
+
diff --git a/README.pt-br.md b/README.pt-br.md
deleted file mode 100644
index 57ed705..0000000
--- a/README.pt-br.md
+++ /dev/null
@@ -1,218 +0,0 @@
-# KaririCode Framework: Componente Dotenv
-
-[](README.md) [](README.pt-br.md)
-
-  
-
-Um componente robusto e flexível para gerenciamento de variáveis de ambiente no KaririCode Framework, fornecendo recursos avançados para lidar com arquivos .env em aplicações PHP.
-
-## Funcionalidades
-
-- Parse e carregamento de variáveis de ambiente a partir de arquivos .env
-- Suporte para interpolação de variáveis
-- **Detecção e conversão automática de tipos**
- - Detecta e converte tipos comuns (string, inteiro, float, booleano, array, JSON)
- - Preserva tipos de dados para uso mais preciso em sua aplicação
-- **Sistema de tipos personalizável**
- - Extensível com detectores e conversores de tipos personalizados
- - Controle refinado sobre como suas variáveis de ambiente são processadas
-- Modo estrito para validação de nomes de variáveis
-- Acesso fácil às variáveis de ambiente por meio de uma função auxiliar global
-- Suporte para estruturas de dados complexas (arrays e JSON) em variáveis de ambiente
-
-## Instalação
-
-Para instalar o componente Dotenv do KaririCode no seu projeto, execute o seguinte comando:
-
-```bash
-composer require kariricode/dotenv
-```
-
-## Uso
-
-### Uso Básico
-
-1. Crie um arquivo `.env` no diretório raiz do seu projeto:
-
-```env
-KARIRI_APP_ENV=develop
-KARIRI_APP_NAME=KaririCode
-KARIRI_PHP_VERSION=8.3
-KARIRI_PHP_PORT=9003
-KARIRI_APP_DEBUG=true
-KARIRI_APP_URL=https://kariricode.com
-KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}"
-KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}}
-KARIRI_ARRAY_CONFIG=["item1", "item2", "item with spaces"]
-```
-
-2. No arquivo de bootstrap da sua aplicação:
-
-```php
-load();
-
-// Agora você pode usar a função env() para acessar suas variáveis de ambiente
-$appName = env('KARIRI_APP_NAME');
-$debug = env('KARIRI_APP_DEBUG');
-$jsonConfig = env('KARIRI_JSON_CONFIG');
-$arrayConfig = env('KARIRI_ARRAY_CONFIG');
-```
-
-### Detecção e Conversão de Tipos
-
-O componente Dotenv do KaririCode detecta e converte automaticamente os seguintes tipos:
-
-- Strings
-- Inteiros
-- Floats
-- Booleanos
-- Valores nulos
-- Arrays
-- Objetos JSON
-
-Exemplo:
-
-```env
-STRING_VAR=Hello World
-INT_VAR=42
-FLOAT_VAR=3.14
-BOOL_VAR=true
-NULL_VAR=null
-ARRAY_VAR=["item1", "item2", "item3"]
-JSON_VAR={"key": "value", "nested": {"subkey": "subvalue"}}
-```
-
-Quando acessadas usando a função `env()`, essas variáveis serão automaticamente convertidas para seus tipos PHP apropriados:
-
-```php
-$stringVar = env('STRING_VAR'); // string: "Hello World"
-$intVar = env('INT_VAR'); // inteiro: 42
-$floatVar = env('FLOAT_VAR'); // float: 3.14
-$boolVar = env('BOOL_VAR'); // booleano: true
-$nullVar = env('NULL_VAR'); // null
-$arrayVar = env('ARRAY_VAR'); // array: ["item1", "item2", "item3"]
-$jsonVar = env('JSON_VAR'); // array: ["key" => "value", "nested" => ["subkey" => "subvalue"]]
-```
-
-Essa tipagem automática garante que você esteja trabalhando com os tipos corretos em sua aplicação, reduzindo erros relacionados a tipos e melhorando a confiabilidade geral do código.
-
-### Uso Avançado
-
-#### Detectores de Tipo Personalizados
-
-Crie detectores de tipo personalizados para lidar com formatos específicos:
-
-```php
-use KaririCode\Dotenv\Type\Detector\AbstractTypeDetector;
-
-class CustomDetector extends AbstractTypeDetector
-{
- public const PRIORITY = 100;
-
- public function detect(mixed $value): ?string
- {
- // Sua lógica de detecção aqui
- // Retorne o tipo detectado como uma string, ou null se não detectado
- }
-}
-
-$dotenv->addTypeDetector(new CustomDetector());
-```
-
-#### Conversores de Tipo Personalizados
-
-Crie conversores de tipo personalizados para lidar com tipos de dados específicos:
-
-```php
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
-
-class CustomCaster implements TypeCaster
-{
- public function cast(mixed $value): mixed
- {
- // Sua lógica de conversão aqui
- }
-}
-
-$dotenv->addTypeCaster('custom_type', new CustomCaster());
-```
-
-## Desenvolvimento e Testes
-
-Para fins de desenvolvimento e teste, este pacote utiliza Docker e Docker Compose para garantir consistência entre diferentes ambientes. Um Makefile é fornecido para conveniência.
-
-### Pré-requisitos
-
-- Docker
-- Docker Compose
-- Make (opcional, mas recomendado para facilitar a execução de comandos)
-
-### Configuração para Desenvolvimento
-
-1. Clone o repositório:
-
- ```bash
- git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git
- cd kariricode-dotenv
- ```
-
-2. Configure o ambiente:
-
- ```bash
- make setup-env
- ```
-
-3. Inicie os containers Docker:
-
- ```bash
- make up
- ```
-
-4. Instale as dependências:
- ```bash
- make composer-install
- ```
-
-### Comandos Make Disponíveis
-
-- `make up`: Inicia todos os serviços em segundo plano
-- `make down`: Para e remove todos os containers
-- `make build`: Constrói as imagens Docker
-- `make shell`: Acessa o shell do container PHP
-- `make test`: Executa os testes
-- `make coverage`: Executa a cobertura de testes com formatação visual
-- `make cs-fix`: Executa o PHP CS Fixer para corrigir o estilo do código
-- `make quality`: Executa todos os comandos de qualidade (cs-check, test, security-check)
-
-Para uma lista completa de comandos disponíveis, execute:
-
-```bash
-make help
-```
-
-## Licença
-
-Este projeto está licenciado sob a Licença MIT - veja o arquivo [LICENSE](LICENSE) para mais detalhes.
-
-## Suporte e Comunidade
-
-- **Documentação**: [https://kariricode.org/docs/dotenv](https://kariricode.org/docs/dotenv)
-- **Rastreador de Problemas**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues)
-- **Comunidade**: [Comunidade KaririCode Club](https://kariricode.club)
-
-## Agradecimentos
-
-- A equipe do KaririCode Framework e contribuidores.
-- Inspirado por outras bibliotecas populares de Dotenv para PHP.
-
----
-
-Construído com ❤️ pela equipe KaririCode. Capacitando desenvolvedores a criar aplicações PHP mais robustas e flexíveis.
diff --git a/bin/kariricode-dotenv b/bin/kariricode-dotenv
new file mode 100755
index 0000000..fa880e1
--- /dev/null
+++ b/bin/kariricode-dotenv
@@ -0,0 +1,510 @@
+#!/usr/bin/env php
+ [options]
+ *
+ * Commands:
+ * debug List all loaded variables with types and sources
+ * validate Validate .env against .env.example or .env.schema
+ * encrypt Encrypt plaintext values in a .env file
+ * decrypt Decrypt encrypted values in a .env file
+ * cache:dump Generate OPcache-friendly PHP cache
+ * cache:clear Remove the PHP cache file
+ * diff Compare two .env files
+ * example:generate Generate .env.example from .env (strips values)
+ * keygen Generate a new encryption key pair
+ */
+
+namespace KaririCode\Dotenv\Console;
+
+// Autoload resolution: find vendor/autoload.php
+(static function (): void {
+ $candidates = [
+ __DIR__ . '/../vendor/autoload.php',
+ __DIR__ . '/../../../autoload.php',
+ ];
+
+ foreach ($candidates as $file) {
+ if (is_file($file)) {
+ require $file;
+
+ return;
+ }
+ }
+
+ fwrite(STDERR, "Cannot find vendor/autoload.php. Run: composer install\n");
+ exit(1);
+})();
+
+use KaririCode\Dotenv\Cache\PhpFileCache;
+use KaririCode\Dotenv\Dotenv;
+use KaririCode\Dotenv\Security\Encryptor;
+use KaririCode\Dotenv\Security\KeyPair;
+use KaririCode\Dotenv\ValueObject\DotenvConfiguration;
+
+// ── Argument Parsing ──────────────────────────────────────────────────
+
+$args = array_slice($argv, 1);
+$command = $args[0] ?? 'help';
+$options = parseOptions(array_slice($args, 1));
+
+match ($command) {
+ 'debug' => commandDebug($options),
+ 'validate' => commandValidate($options),
+ 'encrypt' => commandEncrypt($options),
+ 'decrypt' => commandDecrypt($options),
+ 'cache:dump' => commandCacheDump($options),
+ 'cache:clear' => commandCacheClear($options),
+ 'diff' => commandDiff($options),
+ 'example:generate' => commandExampleGenerate($options),
+ 'keygen' => commandKeygen(),
+ 'help', '--help', '-h' => commandHelp(),
+ default => commandHelp("Unknown command: {$command}"),
+};
+
+// ── Commands ──────────────────────────────────────────────────────────
+
+function commandDebug(array $options): void
+{
+ $dir = $options['dir'] ?? getcwd();
+ $dotenv = new Dotenv($dir);
+
+ try {
+ $dotenv->safeLoad();
+ } catch (\Throwable $e) {
+ stderr("Error loading .env: {$e->getMessage()}");
+ exit(1);
+ }
+
+ $debug = $dotenv->debug();
+
+ if ($debug === []) {
+ stdout("No variables loaded.");
+
+ return;
+ }
+
+ stdout(sprintf("%-30s %-10s %-10s %s", 'VARIABLE', 'TYPE', 'SOURCE', 'VALUE'));
+ stdout(str_repeat('─', 72));
+
+ foreach ($debug as $name => $info) {
+ $display = is_scalar($info['value'])
+ ? var_export($info['value'], true)
+ : gettype($info['value']);
+
+ $override = $info['overridden'] ? ' (overridden)' : '';
+
+ stdout(sprintf(
+ "%-30s %-10s %-10s %s%s",
+ $name,
+ $info['type'],
+ $info['source'],
+ truncate($display, 40),
+ $override,
+ ));
+ }
+
+ stdout("\nTotal: " . count($debug) . " variables.");
+}
+
+function commandValidate(array $options): void
+{
+ $dir = $options['dir'] ?? getcwd();
+ $schemaFile = $options['schema'] ?? null;
+ $exampleFile = $options['example'] ?? null;
+
+ $dotenv = new Dotenv($dir);
+
+ try {
+ $dotenv->safeLoad();
+ } catch (\Throwable $e) {
+ stderr("Error loading .env: {$e->getMessage()}");
+ exit(1);
+ }
+
+ // Schema-based validation
+ if ($schemaFile !== null) {
+ $schemaPath = is_file($schemaFile) ? $schemaFile : $dir . DIRECTORY_SEPARATOR . $schemaFile;
+
+ try {
+ $dotenv->loadWithSchema($schemaPath);
+ stdout("✓ All schema rules passed.");
+ } catch (\Throwable $e) {
+ stderr("✗ Schema validation failed:\n{$e->getMessage()}");
+ exit(1);
+ }
+
+ return;
+ }
+
+ // Example-based validation (check all keys in .env.example exist in .env)
+ $examplePath = $exampleFile ?? $dir . DIRECTORY_SEPARATOR . '.env.example';
+
+ if (!is_file($examplePath)) {
+ stderr("No .env.example or --schema found. Nothing to validate.");
+ exit(1);
+ }
+
+ $exampleContent = file_get_contents($examplePath);
+ $loaded = $dotenv->variables();
+ $missing = [];
+
+ foreach (explode("\n", $exampleContent) as $line) {
+ $line = trim($line);
+
+ if ($line === '' || $line[0] === '#') {
+ continue;
+ }
+
+ if (preg_match('/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/', $line, $m)) {
+ $name = $m[1];
+
+ if (!isset($loaded[$name]) && !isset($_ENV[$name])) {
+ $missing[] = $name;
+ }
+ }
+ }
+
+ if ($missing === []) {
+ stdout("✓ All variables from .env.example are defined.");
+ } else {
+ stderr("✗ Missing variables:\n " . implode("\n ", $missing));
+ exit(1);
+ }
+}
+
+function commandEncrypt(array $options): void
+{
+ $file = $options['_positional'][0] ?? '.env';
+ $keyArg = $options['key'] ?? null;
+
+ if (!is_file($file)) {
+ stderr("File not found: {$file}");
+ exit(1);
+ }
+
+ if ($keyArg === null) {
+ // Generate a new key
+ $keyPair = KeyPair::generate();
+ $keyArg = $keyPair->privateKey;
+ stdout("Generated new encryption key:");
+ stdout("DOTENV_PRIVATE_KEY={$keyPair->privateKey}");
+ stdout("Key ID: {$keyPair->publicId}");
+ stdout("");
+ }
+
+ $encryptor = new Encryptor($keyArg);
+ $content = file_get_contents($file);
+ $lines = explode("\n", $content);
+ $output = [];
+
+ foreach ($lines as $line) {
+ $trimmed = trim($line);
+
+ if ($trimmed === '' || $trimmed[0] === '#' || !str_contains($trimmed, '=')) {
+ $output[] = $line;
+ continue;
+ }
+
+ $eqPos = strpos($trimmed, '=');
+ $key = substr($trimmed, 0, $eqPos);
+ $value = substr($trimmed, $eqPos + 1);
+
+ // Strip quotes
+ if ((str_starts_with($value, '"') && str_ends_with($value, '"'))
+ || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
+ $value = substr($value, 1, -1);
+ }
+
+ // Skip already encrypted
+ if (Encryptor::isEncrypted($value)) {
+ $output[] = $line;
+ continue;
+ }
+
+ $encrypted = $encryptor->encrypt($value);
+ $output[] = "{$key}=\"{$encrypted}\"";
+ }
+
+ file_put_contents($file, implode("\n", $output));
+ stdout("✓ Encrypted values in {$file}");
+ stdout("Store DOTENV_PRIVATE_KEY securely — it is required for decryption.");
+}
+
+function commandDecrypt(array $options): void
+{
+ $file = $options['_positional'][0] ?? '.env';
+ $keyArg = $options['key'] ?? $_SERVER['DOTENV_PRIVATE_KEY'] ?? $_ENV['DOTENV_PRIVATE_KEY'] ?? null;
+
+ if (!is_file($file)) {
+ stderr("File not found: {$file}");
+ exit(1);
+ }
+
+ if ($keyArg === null) {
+ stderr("No decryption key provided. Use --key= or set DOTENV_PRIVATE_KEY.");
+ exit(1);
+ }
+
+ $encryptor = new Encryptor($keyArg);
+ $content = file_get_contents($file);
+ $lines = explode("\n", $content);
+ $output = [];
+
+ foreach ($lines as $line) {
+ $trimmed = trim($line);
+
+ if ($trimmed === '' || $trimmed[0] === '#' || !str_contains($trimmed, '=')) {
+ $output[] = $line;
+ continue;
+ }
+
+ $eqPos = strpos($trimmed, '=');
+ $key = substr($trimmed, 0, $eqPos);
+ $value = substr($trimmed, $eqPos + 1);
+
+ // Strip quotes
+ $raw = $value;
+
+ if ((str_starts_with($value, '"') && str_ends_with($value, '"'))
+ || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
+ $raw = substr($value, 1, -1);
+ }
+
+ if (Encryptor::isEncrypted($raw)) {
+ $decrypted = $encryptor->decrypt($raw);
+ $output[] = "{$key}=\"{$decrypted}\"";
+ } else {
+ $output[] = $line;
+ }
+ }
+
+ file_put_contents($file, implode("\n", $output));
+ stdout("✓ Decrypted values in {$file}");
+}
+
+function commandCacheDump(array $options): void
+{
+ $dir = $options['dir'] ?? getcwd();
+ $output = $options['output'] ?? $dir . DIRECTORY_SEPARATOR . '.env.cache.php';
+
+ $dotenv = new Dotenv($dir);
+
+ try {
+ $dotenv->safeLoad();
+ } catch (\Throwable $e) {
+ stderr("Error loading .env: {$e->getMessage()}");
+ exit(1);
+ }
+
+ $dotenv->dumpCache($output);
+ stdout("✓ Cache written to {$output}");
+}
+
+function commandCacheClear(array $options): void
+{
+ $dir = $options['dir'] ?? getcwd();
+ $path = $options['path'] ?? $dir . DIRECTORY_SEPARATOR . '.env.cache.php';
+
+ (new PhpFileCache())->clear($path);
+ stdout("✓ Cache cleared: {$path}");
+}
+
+function commandDiff(array $options): void
+{
+ $files = $options['_positional'] ?? [];
+
+ if (count($files) < 2) {
+ stderr("Usage: kariricode-dotenv diff ");
+ exit(1);
+ }
+
+ $vars1 = parseEnvFileKeys($files[0]);
+ $vars2 = parseEnvFileKeys($files[1]);
+
+ $onlyIn1 = array_diff_key($vars1, $vars2);
+ $onlyIn2 = array_diff_key($vars2, $vars1);
+ $different = [];
+
+ foreach ($vars1 as $key => $val) {
+ if (isset($vars2[$key]) && $vars2[$key] !== $val) {
+ $different[$key] = [$val, $vars2[$key]];
+ }
+ }
+
+ $base1 = basename($files[0]);
+ $base2 = basename($files[1]);
+
+ if ($onlyIn1 === [] && $onlyIn2 === [] && $different === []) {
+ stdout("✓ Files are identical.");
+
+ return;
+ }
+
+ if ($onlyIn1 !== []) {
+ stdout("Only in {$base1}:");
+
+ foreach ($onlyIn1 as $key => $val) {
+ stdout(" + {$key}");
+ }
+ }
+
+ if ($onlyIn2 !== []) {
+ stdout("Only in {$base2}:");
+
+ foreach ($onlyIn2 as $key => $val) {
+ stdout(" + {$key}");
+ }
+ }
+
+ if ($different !== []) {
+ stdout("Different values:");
+
+ foreach ($different as $key => [$v1, $v2]) {
+ stdout(" ~ {$key}");
+ stdout(" {$base1}: {$v1}");
+ stdout(" {$base2}: {$v2}");
+ }
+ }
+}
+
+function commandExampleGenerate(array $options): void
+{
+ $input = $options['_positional'][0] ?? '.env';
+ $output = $options['output'] ?? '.env.example';
+
+ if (!is_file($input)) {
+ stderr("File not found: {$input}");
+ exit(1);
+ }
+
+ $content = file_get_contents($input);
+ $lines = explode("\n", $content);
+ $result = [];
+
+ foreach ($lines as $line) {
+ $trimmed = trim($line);
+
+ if ($trimmed === '' || $trimmed[0] === '#') {
+ $result[] = $line;
+ continue;
+ }
+
+ if (preg_match('/^((?:export\s+)?[A-Za-z_][A-Za-z0-9_]*)=/', $trimmed, $m)) {
+ $result[] = $m[1] . '=';
+ } else {
+ $result[] = $line;
+ }
+ }
+
+ file_put_contents($output, implode("\n", $result));
+ stdout("✓ Generated {$output} from {$input}");
+}
+
+function commandKeygen(): void
+{
+ $keyPair = KeyPair::generate();
+ stdout("Key ID: {$keyPair->publicId}");
+ stdout("DOTENV_PRIVATE_KEY= {$keyPair->privateKey}");
+ stdout("");
+ stdout("Add to your server environment (NOT to .env files):");
+ stdout(" export DOTENV_PRIVATE_KEY={$keyPair->privateKey}");
+}
+
+function commandHelp(string $error = ''): void
+{
+ if ($error !== '') {
+ stderr($error);
+ stderr('');
+ }
+
+ stdout('KaririCode Dotenv CLI v4.x');
+ stdout('');
+ stdout('Usage: kariricode-dotenv [options]');
+ stdout('');
+ stdout('Commands:');
+ stdout(' debug List all loaded variables with types and sources');
+ stdout(' validate Validate .env against .env.example or --schema=file');
+ stdout(' encrypt [file] Encrypt plaintext values (--key= or auto-generate)');
+ stdout(' decrypt [file] Decrypt encrypted values (--key= or DOTENV_PRIVATE_KEY)');
+ stdout(' cache:dump Generate OPcache-friendly .env.cache.php');
+ stdout(' cache:clear Remove the cache file');
+ stdout(' diff Compare two .env files');
+ stdout(' example:generate Generate .env.example from .env (strips values)');
+ stdout(' keygen Generate a new encryption key pair');
+ stdout('');
+ stdout('Options:');
+ stdout(' --dir= Project directory (default: cwd)');
+ stdout(' --schema= Schema file for validation');
+ stdout(' --example= Example file for validation');
+ stdout(' --key= Encryption/decryption key (64-char hex)');
+ stdout(' --output= Output file path');
+
+ exit($error !== '' ? 1 : 0);
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────
+
+function parseOptions(array $args): array
+{
+ $options = ['_positional' => []];
+
+ foreach ($args as $arg) {
+ if (str_starts_with($arg, '--') && str_contains($arg, '=')) {
+ $eqPos = strpos($arg, '=');
+ $key = substr($arg, 2, $eqPos - 2);
+ $options[$key] = substr($arg, $eqPos + 1);
+ } elseif (str_starts_with($arg, '--')) {
+ $options[substr($arg, 2)] = true;
+ } else {
+ $options['_positional'][] = $arg;
+ }
+ }
+
+ return $options;
+}
+
+function parseEnvFileKeys(string $file): array
+{
+ if (!is_file($file)) {
+ stderr("File not found: {$file}");
+ exit(1);
+ }
+
+ $vars = [];
+
+ foreach (explode("\n", file_get_contents($file)) as $line) {
+ $line = trim($line);
+
+ if ($line === '' || $line[0] === '#') {
+ continue;
+ }
+
+ if (preg_match('/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/', $line, $m)) {
+ $vars[$m[1]] = $m[2];
+ }
+ }
+
+ return $vars;
+}
+
+function stdout(string $msg): void
+{
+ fwrite(STDOUT, $msg . "\n");
+}
+
+function stderr(string $msg): void
+{
+ fwrite(STDERR, $msg . "\n");
+}
+
+function truncate(string $str, int $max): string
+{
+ return strlen($str) > $max ? substr($str, 0, $max - 3) . '...' : $str;
+}
diff --git a/composer.json b/composer.json
index ac1f093..a8afe3f 100644
--- a/composer.json
+++ b/composer.json
@@ -1,52 +1,65 @@
{
- "name": "kariricode/dotenv",
- "description": "A highly optimized, extensible, and reliable Dotenv component designed to load environment variables for PHP applications in the KaririCode Framework, ensuring secure and efficient configuration management.",
- "keywords": [
- "dotenv",
- "environment variables",
- "configuration",
- "PHP",
- "Framework",
- "secure",
- "flexible",
- "extensible",
- "kaririCode"
- ],
- "homepage": "https://kariricode.org",
- "type": "library",
- "license": "MIT",
- "authors": [
- {
- "name": "Walmir Silva",
- "email": "community@kariricode.org"
- }
- ],
- "require": {
- "php": "^8.3",
- "kariricode/data-structure": "^1.0"
- },
- "autoload": {
- "psr-4": {
- "KaririCode\\Dotenv\\": "src"
+ "name": "kariricode/dotenv",
+ "description": "The only PHP dotenv with auto type casting, AES-256-GCM encryption, OPcache caching, fluent validation DSL, environment-aware loading, and CLI tooling — zero dependencies, PHP 8.4+, ARFA 1.3.",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "framework",
+ "php",
+ "configuration",
+ "dotenv",
+ "environment-variables",
+ "env",
+ "kariricode",
+ "type-safe",
+ "immutable",
+ "encrypted",
+ "validation",
+ "cache",
+ "php84"
+ ],
+ "authors": [
+ {
+ "name": "Walmir Silva",
+ "email": "community@kariricode.org"
+ }
+ ],
+ "homepage": "https://kariricode.org",
+ "bin": [
+ "bin/kariricode-dotenv"
+ ],
+ "require": {
+ "php": "^8.4"
},
- "files": [
- "src/functions.php"
- ]
- },
- "autoload-dev": {
- "psr-4": {
- "KaririCode\\Dotenv\\Tests\\": "tests"
- }
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "^3.51",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^11.0",
- "squizlabs/php_codesniffer": "^3.9",
- "enlightn/security-checker": "^2.0"
- },
- "support": {
- "issues": "https://github.com/KaririCode-Framework/kariricode-dotenv/issues",
- "source": "https://github.com/KaririCode-Framework/kariricode-dotenv"
- }
+ "suggest": {
+ "ext-openssl": "Required for encrypted .env values (AES-256-GCM)"
+ },
+ "autoload": {
+ "psr-4": {
+ "KaririCode\\Dotenv\\": "src/"
+ },
+ "files": [
+ "src/env.php"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "KaririCode\\Dotenv\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "analyse": "phpstan analyse",
+ "cs-fix": "php-cs-fixer fix"
+ },
+ "config": {
+ "sort-packages": true,
+ "optimize-autoloader": true
+ },
+ "support": {
+ "issues": "https://github.com/KaririCode-Framework/kariricode-dotenv/issues",
+ "source": "https://github.com/KaririCode-Framework/kariricode-dotenv"
+ },
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
diff --git a/composer.lock b/composer.lock
index d992727..6a23a46 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,5055 +4,17 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "ac88ff6156fd02e4b855fd84934f350c",
- "packages": [
- {
- "name": "kariricode/contract",
- "version": "v2.6.3",
- "source": {
- "type": "git",
- "url": "https://github.com/KaririCode-Framework/kariricode-contract.git",
- "reference": "5d98b009c7c5c20dd63b4440405ac81f93544e7d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/KaririCode-Framework/kariricode-contract/zipball/5d98b009c7c5c20dd63b4440405ac81f93544e7d",
- "reference": "5d98b009c7c5c20dd63b4440405ac81f93544e7d",
- "shasum": ""
- },
- "require": {
- "php": "^8.3"
- },
- "require-dev": {
- "enlightn/security-checker": "^2.0",
- "friendsofphp/php-cs-fixer": "^3.58",
- "mockery/mockery": "^1.6",
- "nunomaduro/phpinsights": "^2.11",
- "phpunit/phpunit": "^10.5",
- "squizlabs/php_codesniffer": "^3.10"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "KaririCode\\Contract\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Walmir Silva",
- "email": "walmir.silva@kariricode.org"
- }
- ],
- "description": "Central repository for interface definitions in the KaririCode Framework. Implements interface code in PHP for specified namespaces, adhering to PSR standards and leveraging modern PHP features.",
- "homepage": "https://kariricode.org/",
- "keywords": [
- "PSRs",
- "contract",
- "framework",
- "interface",
- "kariri",
- "php"
- ],
- "support": {
- "issues": "https://github.com/KaririCode-Framework/kariricode-contract/issues",
- "source": "https://github.com/KaririCode-Framework/kariricode-contract"
- },
- "time": "2024-10-10T21:05:49+00:00"
- },
- {
- "name": "kariricode/data-structure",
- "version": "v1.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/KaririCode-Framework/kariricode-data-structure.git",
- "reference": "91c9e7ef36143b5e3d449f1a132169580945a4c8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/KaririCode-Framework/kariricode-data-structure/zipball/91c9e7ef36143b5e3d449f1a132169580945a4c8",
- "reference": "91c9e7ef36143b5e3d449f1a132169580945a4c8",
- "shasum": ""
- },
- "require": {
- "kariricode/contract": "^2.0",
- "php": "^8.3"
- },
- "require-dev": {
- "enlightn/security-checker": "^2.0",
- "friendsofphp/php-cs-fixer": "^3.58",
- "mockery/mockery": "^1.6",
- "nunomaduro/phpinsights": "^2.11",
- "phpunit/phpunit": "^10.5",
- "squizlabs/php_codesniffer": "^3.10"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "KaririCode\\DataStructure\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Walmir Silva",
- "email": "walmir.silva@kariricode.org"
- }
- ],
- "description": "The KaririCode DataStructure component offers advanced PHP data structures, including lists, stacks, queues, maps, and sets. It features efficient, strongly-typed, object-oriented implementations like ArrayList, LinkedList, BinaryHeap, and TreeMap.",
- "homepage": "https://kariricode.org/",
- "keywords": [
- "KaririCode",
- "LinkedList",
- "OOP",
- "algorithms",
- "arraylist",
- "binary heap",
- "collections",
- "data structures",
- "dynamic array",
- "heap",
- "php",
- "queue",
- "red-black tree",
- "set",
- "stack",
- "strong typing",
- "treemap"
- ],
- "support": {
- "issues": "https://github.com/KaririCode-Framework/kariricode-data-structure/issues",
- "source": "https://github.com/KaririCode-Framework/kariricode-data-structure"
- },
- "time": "2024-10-10T22:37:23+00:00"
- }
- ],
- "packages-dev": [
- {
- "name": "clue/ndjson-react",
- "version": "v1.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/clue/reactphp-ndjson.git",
- "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
- "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3",
- "react/stream": "^1.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
- "react/event-loop": "^1.2"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Clue\\React\\NDJson\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering"
- }
- ],
- "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
- "homepage": "https://github.com/clue/reactphp-ndjson",
- "keywords": [
- "NDJSON",
- "json",
- "jsonlines",
- "newline",
- "reactphp",
- "streaming"
- ],
- "support": {
- "issues": "https://github.com/clue/reactphp-ndjson/issues",
- "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
- },
- "funding": [
- {
- "url": "https://clue.engineering/support",
- "type": "custom"
- },
- {
- "url": "https://github.com/clue",
- "type": "github"
- }
- ],
- "time": "2022-12-23T10:58:28+00:00"
- },
- {
- "name": "composer/pcre",
- "version": "3.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/composer/pcre.git",
- "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4",
- "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4",
- "shasum": ""
- },
- "require": {
- "php": "^7.4 || ^8.0"
- },
- "conflict": {
- "phpstan/phpstan": "<1.11.10"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.11.10",
- "phpstan/phpstan-strict-rules": "^1.1",
- "phpunit/phpunit": "^8 || ^9"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.x-dev"
- },
- "phpstan": {
- "includes": [
- "extension.neon"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Composer\\Pcre\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Jordi Boggiano",
- "email": "j.boggiano@seld.be",
- "homepage": "http://seld.be"
- }
- ],
- "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
- "keywords": [
- "PCRE",
- "preg",
- "regex",
- "regular expression"
- ],
- "support": {
- "issues": "https://github.com/composer/pcre/issues",
- "source": "https://github.com/composer/pcre/tree/3.3.1"
- },
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
- "time": "2024-08-27T18:44:43+00:00"
- },
- {
- "name": "composer/semver",
- "version": "3.4.3",
- "source": {
- "type": "git",
- "url": "https://github.com/composer/semver.git",
- "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12",
- "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12",
- "shasum": ""
- },
- "require": {
- "php": "^5.3.2 || ^7.0 || ^8.0"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.11",
- "symfony/phpunit-bridge": "^3 || ^7"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Composer\\Semver\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nils Adermann",
- "email": "naderman@naderman.de",
- "homepage": "http://www.naderman.de"
- },
- {
- "name": "Jordi Boggiano",
- "email": "j.boggiano@seld.be",
- "homepage": "http://seld.be"
- },
- {
- "name": "Rob Bast",
- "email": "rob.bast@gmail.com",
- "homepage": "http://robbast.nl"
- }
- ],
- "description": "Semver library that offers utilities, version constraint parsing and validation.",
- "keywords": [
- "semantic",
- "semver",
- "validation",
- "versioning"
- ],
- "support": {
- "irc": "ircs://irc.libera.chat:6697/composer",
- "issues": "https://github.com/composer/semver/issues",
- "source": "https://github.com/composer/semver/tree/3.4.3"
- },
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-19T14:15:21+00:00"
- },
- {
- "name": "composer/xdebug-handler",
- "version": "3.0.5",
- "source": {
- "type": "git",
- "url": "https://github.com/composer/xdebug-handler.git",
- "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
- "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
- "shasum": ""
- },
- "require": {
- "composer/pcre": "^1 || ^2 || ^3",
- "php": "^7.2.5 || ^8.0",
- "psr/log": "^1 || ^2 || ^3"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.0",
- "phpstan/phpstan-strict-rules": "^1.1",
- "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Composer\\XdebugHandler\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "John Stevenson",
- "email": "john-stevenson@blueyonder.co.uk"
- }
- ],
- "description": "Restarts a process without Xdebug.",
- "keywords": [
- "Xdebug",
- "performance"
- ],
- "support": {
- "irc": "ircs://irc.libera.chat:6697/composer",
- "issues": "https://github.com/composer/xdebug-handler/issues",
- "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
- },
- "funding": [
- {
- "url": "https://packagist.com",
- "type": "custom"
- },
- {
- "url": "https://github.com/composer",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/composer/composer",
- "type": "tidelift"
- }
- ],
- "time": "2024-05-06T16:37:16+00:00"
- },
- {
- "name": "enlightn/security-checker",
- "version": "v2.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/enlightn/security-checker.git",
- "reference": "d495ab07639388c7c770c5223aa0d42fee1d2604"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/enlightn/security-checker/zipball/d495ab07639388c7c770c5223aa0d42fee1d2604",
- "reference": "d495ab07639388c7c770c5223aa0d42fee1d2604",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "guzzlehttp/guzzle": "^6.3|^7.0",
- "php": ">=8.2",
- "symfony/console": "^7",
- "symfony/finder": "^3|^4|^5|^6|^7",
- "symfony/process": "^3.4|^4|^5|^6|^7",
- "symfony/yaml": "^3.4|^4|^5|^6|^7"
- },
- "require-dev": {
- "ext-zip": "*",
- "friendsofphp/php-cs-fixer": "^2.18|^3.0",
- "phpunit/phpunit": "^5.5|^6|^7|^8|^9"
- },
- "bin": [
- "security-checker"
- ],
- "type": "library",
- "autoload": {
- "psr-4": {
- "Enlightn\\SecurityChecker\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Paras Malhotra",
- "email": "paras@laravel-enlightn.com"
- },
- {
- "name": "Miguel Piedrafita",
- "email": "soy@miguelpiedrafita.com"
- }
- ],
- "description": "A PHP dependency vulnerabilities scanner based on the Security Advisories Database.",
- "keywords": [
- "package",
- "php",
- "scanner",
- "security",
- "security advisories",
- "vulnerability scanner"
- ],
- "support": {
- "issues": "https://github.com/enlightn/security-checker/issues",
- "source": "https://github.com/enlightn/security-checker/tree/v2.0.0"
- },
- "time": "2023-12-10T07:17:09+00:00"
- },
- {
- "name": "evenement/evenement",
- "version": "v3.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/igorw/evenement.git",
- "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
- "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
- "shasum": ""
- },
- "require": {
- "php": ">=7.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^9 || ^6"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Evenement\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Igor Wiedler",
- "email": "igor@wiedler.ch"
- }
- ],
- "description": "Événement is a very simple event dispatching library for PHP",
- "keywords": [
- "event-dispatcher",
- "event-emitter"
- ],
- "support": {
- "issues": "https://github.com/igorw/evenement/issues",
- "source": "https://github.com/igorw/evenement/tree/v3.0.2"
- },
- "time": "2023-08-08T05:53:35+00:00"
- },
- {
- "name": "fidry/cpu-core-counter",
- "version": "1.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/theofidry/cpu-core-counter.git",
- "reference": "8520451a140d3f46ac33042715115e290cf5785f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f",
- "reference": "8520451a140d3f46ac33042715115e290cf5785f",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "require-dev": {
- "fidry/makefile": "^0.2.0",
- "fidry/php-cs-fixer-config": "^1.1.2",
- "phpstan/extension-installer": "^1.2.0",
- "phpstan/phpstan": "^1.9.2",
- "phpstan/phpstan-deprecation-rules": "^1.0.0",
- "phpstan/phpstan-phpunit": "^1.2.2",
- "phpstan/phpstan-strict-rules": "^1.4.4",
- "phpunit/phpunit": "^8.5.31 || ^9.5.26",
- "webmozarts/strict-phpunit": "^7.5"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Fidry\\CpuCoreCounter\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Théo FIDRY",
- "email": "theo.fidry@gmail.com"
- }
- ],
- "description": "Tiny utility to get the number of CPU cores.",
- "keywords": [
- "CPU",
- "core"
- ],
- "support": {
- "issues": "https://github.com/theofidry/cpu-core-counter/issues",
- "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0"
- },
- "funding": [
- {
- "url": "https://github.com/theofidry",
- "type": "github"
- }
- ],
- "time": "2024-08-06T10:04:20+00:00"
- },
- {
- "name": "friendsofphp/php-cs-fixer",
- "version": "v3.64.0",
- "source": {
- "type": "git",
- "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
- "reference": "58dd9c931c785a79739310aef5178928305ffa67"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/58dd9c931c785a79739310aef5178928305ffa67",
- "reference": "58dd9c931c785a79739310aef5178928305ffa67",
- "shasum": ""
- },
- "require": {
- "clue/ndjson-react": "^1.0",
- "composer/semver": "^3.4",
- "composer/xdebug-handler": "^3.0.3",
- "ext-filter": "*",
- "ext-json": "*",
- "ext-tokenizer": "*",
- "fidry/cpu-core-counter": "^1.0",
- "php": "^7.4 || ^8.0",
- "react/child-process": "^0.6.5",
- "react/event-loop": "^1.0",
- "react/promise": "^2.0 || ^3.0",
- "react/socket": "^1.0",
- "react/stream": "^1.0",
- "sebastian/diff": "^4.0 || ^5.0 || ^6.0",
- "symfony/console": "^5.4 || ^6.0 || ^7.0",
- "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0",
- "symfony/filesystem": "^5.4 || ^6.0 || ^7.0",
- "symfony/finder": "^5.4 || ^6.0 || ^7.0",
- "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0",
- "symfony/polyfill-mbstring": "^1.28",
- "symfony/polyfill-php80": "^1.28",
- "symfony/polyfill-php81": "^1.28",
- "symfony/process": "^5.4 || ^6.0 || ^7.0",
- "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0"
- },
- "require-dev": {
- "facile-it/paraunit": "^1.3 || ^2.3",
- "infection/infection": "^0.29.5",
- "justinrainbow/json-schema": "^5.2",
- "keradus/cli-executor": "^2.1",
- "mikey179/vfsstream": "^1.6.11",
- "php-coveralls/php-coveralls": "^2.7",
- "php-cs-fixer/accessible-object": "^1.1",
- "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5",
- "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5",
- "phpunit/phpunit": "^9.6.19 || ^10.5.21 || ^11.2",
- "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0",
- "symfony/yaml": "^5.4 || ^6.0 || ^7.0"
- },
- "suggest": {
- "ext-dom": "For handling output formats in XML",
- "ext-mbstring": "For handling non-UTF8 characters."
- },
- "bin": [
- "php-cs-fixer"
- ],
- "type": "application",
- "autoload": {
- "psr-4": {
- "PhpCsFixer\\": "src/"
- },
- "exclude-from-classmap": [
- "src/Fixer/Internal/*"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Dariusz Rumiński",
- "email": "dariusz.ruminski@gmail.com"
- }
- ],
- "description": "A tool to automatically fix PHP code style",
- "keywords": [
- "Static code analysis",
- "fixer",
- "standards",
- "static analysis"
- ],
- "support": {
- "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
- "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.64.0"
- },
- "funding": [
- {
- "url": "https://github.com/keradus",
- "type": "github"
- }
- ],
- "time": "2024-08-30T23:09:38+00:00"
- },
- {
- "name": "guzzlehttp/guzzle",
- "version": "7.9.2",
- "source": {
- "type": "git",
- "url": "https://github.com/guzzle/guzzle.git",
- "reference": "d281ed313b989f213357e3be1a179f02196ac99b"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
- "reference": "d281ed313b989f213357e3be1a179f02196ac99b",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "guzzlehttp/promises": "^1.5.3 || ^2.0.3",
- "guzzlehttp/psr7": "^2.7.0",
- "php": "^7.2.5 || ^8.0",
- "psr/http-client": "^1.0",
- "symfony/deprecation-contracts": "^2.2 || ^3.0"
- },
- "provide": {
- "psr/http-client-implementation": "1.0"
- },
- "require-dev": {
- "bamarni/composer-bin-plugin": "^1.8.2",
- "ext-curl": "*",
- "guzzle/client-integration-tests": "3.0.2",
- "php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20",
- "psr/log": "^1.1 || ^2.0 || ^3.0"
- },
- "suggest": {
- "ext-curl": "Required for CURL handler support",
- "ext-intl": "Required for Internationalized Domain Name (IDN) support",
- "psr/log": "Required for using the Log middleware"
- },
- "type": "library",
- "extra": {
- "bamarni-bin": {
- "bin-links": true,
- "forward-command": false
- }
- },
- "autoload": {
- "files": [
- "src/functions_include.php"
- ],
- "psr-4": {
- "GuzzleHttp\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Graham Campbell",
- "email": "hello@gjcampbell.co.uk",
- "homepage": "https://github.com/GrahamCampbell"
- },
- {
- "name": "Michael Dowling",
- "email": "mtdowling@gmail.com",
- "homepage": "https://github.com/mtdowling"
- },
- {
- "name": "Jeremy Lindblom",
- "email": "jeremeamia@gmail.com",
- "homepage": "https://github.com/jeremeamia"
- },
- {
- "name": "George Mponos",
- "email": "gmponos@gmail.com",
- "homepage": "https://github.com/gmponos"
- },
- {
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com",
- "homepage": "https://github.com/Nyholm"
- },
- {
- "name": "Márk Sági-Kazár",
- "email": "mark.sagikazar@gmail.com",
- "homepage": "https://github.com/sagikazarmark"
- },
- {
- "name": "Tobias Schultze",
- "email": "webmaster@tubo-world.de",
- "homepage": "https://github.com/Tobion"
- }
- ],
- "description": "Guzzle is a PHP HTTP client library",
- "keywords": [
- "client",
- "curl",
- "framework",
- "http",
- "http client",
- "psr-18",
- "psr-7",
- "rest",
- "web service"
- ],
- "support": {
- "issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.9.2"
- },
- "funding": [
- {
- "url": "https://github.com/GrahamCampbell",
- "type": "github"
- },
- {
- "url": "https://github.com/Nyholm",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
- "type": "tidelift"
- }
- ],
- "time": "2024-07-24T11:22:20+00:00"
- },
- {
- "name": "guzzlehttp/promises",
- "version": "2.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/guzzle/promises.git",
- "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8",
- "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8",
- "shasum": ""
- },
- "require": {
- "php": "^7.2.5 || ^8.0"
- },
- "require-dev": {
- "bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20"
- },
- "type": "library",
- "extra": {
- "bamarni-bin": {
- "bin-links": true,
- "forward-command": false
- }
- },
- "autoload": {
- "psr-4": {
- "GuzzleHttp\\Promise\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Graham Campbell",
- "email": "hello@gjcampbell.co.uk",
- "homepage": "https://github.com/GrahamCampbell"
- },
- {
- "name": "Michael Dowling",
- "email": "mtdowling@gmail.com",
- "homepage": "https://github.com/mtdowling"
- },
- {
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com",
- "homepage": "https://github.com/Nyholm"
- },
- {
- "name": "Tobias Schultze",
- "email": "webmaster@tubo-world.de",
- "homepage": "https://github.com/Tobion"
- }
- ],
- "description": "Guzzle promises library",
- "keywords": [
- "promise"
- ],
- "support": {
- "issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.0.3"
- },
- "funding": [
- {
- "url": "https://github.com/GrahamCampbell",
- "type": "github"
- },
- {
- "url": "https://github.com/Nyholm",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
- "type": "tidelift"
- }
- ],
- "time": "2024-07-18T10:29:17+00:00"
- },
- {
- "name": "guzzlehttp/psr7",
- "version": "2.7.0",
- "source": {
- "type": "git",
- "url": "https://github.com/guzzle/psr7.git",
- "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
- "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
- "shasum": ""
- },
- "require": {
- "php": "^7.2.5 || ^8.0",
- "psr/http-factory": "^1.0",
- "psr/http-message": "^1.1 || ^2.0",
- "ralouphie/getallheaders": "^3.0"
- },
- "provide": {
- "psr/http-factory-implementation": "1.0",
- "psr/http-message-implementation": "1.0"
- },
- "require-dev": {
- "bamarni/composer-bin-plugin": "^1.8.2",
- "http-interop/http-factory-tests": "0.9.0",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20"
- },
- "suggest": {
- "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
- },
- "type": "library",
- "extra": {
- "bamarni-bin": {
- "bin-links": true,
- "forward-command": false
- }
- },
- "autoload": {
- "psr-4": {
- "GuzzleHttp\\Psr7\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Graham Campbell",
- "email": "hello@gjcampbell.co.uk",
- "homepage": "https://github.com/GrahamCampbell"
- },
- {
- "name": "Michael Dowling",
- "email": "mtdowling@gmail.com",
- "homepage": "https://github.com/mtdowling"
- },
- {
- "name": "George Mponos",
- "email": "gmponos@gmail.com",
- "homepage": "https://github.com/gmponos"
- },
- {
- "name": "Tobias Nyholm",
- "email": "tobias.nyholm@gmail.com",
- "homepage": "https://github.com/Nyholm"
- },
- {
- "name": "Márk Sági-Kazár",
- "email": "mark.sagikazar@gmail.com",
- "homepage": "https://github.com/sagikazarmark"
- },
- {
- "name": "Tobias Schultze",
- "email": "webmaster@tubo-world.de",
- "homepage": "https://github.com/Tobion"
- },
- {
- "name": "Márk Sági-Kazár",
- "email": "mark.sagikazar@gmail.com",
- "homepage": "https://sagikazarmark.hu"
- }
- ],
- "description": "PSR-7 message implementation that also provides common utility methods",
- "keywords": [
- "http",
- "message",
- "psr-7",
- "request",
- "response",
- "stream",
- "uri",
- "url"
- ],
- "support": {
- "issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.7.0"
- },
- "funding": [
- {
- "url": "https://github.com/GrahamCampbell",
- "type": "github"
- },
- {
- "url": "https://github.com/Nyholm",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
- "type": "tidelift"
- }
- ],
- "time": "2024-07-18T11:15:46+00:00"
- },
- {
- "name": "myclabs/deep-copy",
- "version": "1.12.0",
- "source": {
- "type": "git",
- "url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
- "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c",
- "shasum": ""
- },
- "require": {
- "php": "^7.1 || ^8.0"
- },
- "conflict": {
- "doctrine/collections": "<1.6.8",
- "doctrine/common": "<2.13.3 || >=3 <3.2.2"
- },
- "require-dev": {
- "doctrine/collections": "^1.6.8",
- "doctrine/common": "^2.13.3 || ^3.2.2",
- "phpspec/prophecy": "^1.10",
- "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/DeepCopy/deep_copy.php"
- ],
- "psr-4": {
- "DeepCopy\\": "src/DeepCopy/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Create deep copies (clones) of your objects",
- "keywords": [
- "clone",
- "copy",
- "duplicate",
- "object",
- "object graph"
- ],
- "support": {
- "issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0"
- },
- "funding": [
- {
- "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
- "type": "tidelift"
- }
- ],
- "time": "2024-06-12T14:39:25+00:00"
- },
- {
- "name": "nikic/php-parser",
- "version": "v5.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b",
- "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b",
- "shasum": ""
- },
- "require": {
- "ext-ctype": "*",
- "ext-json": "*",
- "ext-tokenizer": "*",
- "php": ">=7.4"
- },
- "require-dev": {
- "ircmaxell/php-yacc": "^0.0.7",
- "phpunit/phpunit": "^9.0"
- },
- "bin": [
- "bin/php-parse"
- ],
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "5.0-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "PhpParser\\": "lib/PhpParser"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Nikita Popov"
- }
- ],
- "description": "A PHP parser written in PHP",
- "keywords": [
- "parser",
- "php"
- ],
- "support": {
- "issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1"
- },
- "time": "2024-10-08T18:51:32+00:00"
- },
- {
- "name": "phar-io/manifest",
- "version": "2.0.4",
- "source": {
- "type": "git",
- "url": "https://github.com/phar-io/manifest.git",
- "reference": "54750ef60c58e43759730615a392c31c80e23176"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
- "reference": "54750ef60c58e43759730615a392c31c80e23176",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "ext-libxml": "*",
- "ext-phar": "*",
- "ext-xmlwriter": "*",
- "phar-io/version": "^3.0.1",
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Arne Blankerts",
- "email": "arne@blankerts.de",
- "role": "Developer"
- },
- {
- "name": "Sebastian Heuer",
- "email": "sebastian@phpeople.de",
- "role": "Developer"
- },
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "Developer"
- }
- ],
- "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
- "support": {
- "issues": "https://github.com/phar-io/manifest/issues",
- "source": "https://github.com/phar-io/manifest/tree/2.0.4"
- },
- "funding": [
- {
- "url": "https://github.com/theseer",
- "type": "github"
- }
- ],
- "time": "2024-03-03T12:33:53+00:00"
- },
- {
- "name": "phar-io/version",
- "version": "3.2.1",
- "source": {
- "type": "git",
- "url": "https://github.com/phar-io/version.git",
- "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
- "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Arne Blankerts",
- "email": "arne@blankerts.de",
- "role": "Developer"
- },
- {
- "name": "Sebastian Heuer",
- "email": "sebastian@phpeople.de",
- "role": "Developer"
- },
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "Developer"
- }
- ],
- "description": "Library for handling version information and constraints",
- "support": {
- "issues": "https://github.com/phar-io/version/issues",
- "source": "https://github.com/phar-io/version/tree/3.2.1"
- },
- "time": "2022-02-21T01:04:05+00:00"
- },
- {
- "name": "phpstan/phpstan",
- "version": "1.12.6",
- "source": {
- "type": "git",
- "url": "https://github.com/phpstan/phpstan.git",
- "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc4d2f145a88ea7141ae698effd64d9df46527ae",
- "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae",
- "shasum": ""
- },
- "require": {
- "php": "^7.2|^8.0"
- },
- "conflict": {
- "phpstan/phpstan-shim": "*"
- },
- "bin": [
- "phpstan",
- "phpstan.phar"
- ],
- "type": "library",
- "autoload": {
- "files": [
- "bootstrap.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "PHPStan - PHP Static Analysis Tool",
- "keywords": [
- "dev",
- "static analysis"
- ],
- "support": {
- "docs": "https://phpstan.org/user-guide/getting-started",
- "forum": "https://github.com/phpstan/phpstan/discussions",
- "issues": "https://github.com/phpstan/phpstan/issues",
- "security": "https://github.com/phpstan/phpstan/security/policy",
- "source": "https://github.com/phpstan/phpstan-src"
- },
- "funding": [
- {
- "url": "https://github.com/ondrejmirtes",
- "type": "github"
- },
- {
- "url": "https://github.com/phpstan",
- "type": "github"
- }
- ],
- "time": "2024-10-06T15:03:59+00:00"
- },
- {
- "name": "phpunit/php-code-coverage",
- "version": "11.0.7",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7f08030e8811582cc459871d28d6f5a1a4d35ca",
- "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "ext-libxml": "*",
- "ext-xmlwriter": "*",
- "nikic/php-parser": "^5.3.1",
- "php": ">=8.2",
- "phpunit/php-file-iterator": "^5.1.0",
- "phpunit/php-text-template": "^4.0.1",
- "sebastian/code-unit-reverse-lookup": "^4.0.1",
- "sebastian/complexity": "^4.0.1",
- "sebastian/environment": "^7.2.0",
- "sebastian/lines-of-code": "^3.0.1",
- "sebastian/version": "^5.0.2",
- "theseer/tokenizer": "^1.2.3"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.4.1"
- },
- "suggest": {
- "ext-pcov": "PHP extension that provides line coverage",
- "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "11.0.x-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
- "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
- "keywords": [
- "coverage",
- "testing",
- "xunit"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
- "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.7"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-10-09T06:21:38+00:00"
- },
- {
- "name": "phpunit/php-file-iterator",
- "version": "5.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "5.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "FilterIterator implementation that filters files based on a list of suffixes.",
- "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
- "keywords": [
- "filesystem",
- "iterator"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
- "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
- "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-08-27T05:02:59+00:00"
- },
- {
- "name": "phpunit/php-invoker",
- "version": "5.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-invoker.git",
- "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2",
- "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "ext-pcntl": "*",
- "phpunit/phpunit": "^11.0"
- },
- "suggest": {
- "ext-pcntl": "*"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "5.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Invoke callables with a timeout",
- "homepage": "https://github.com/sebastianbergmann/php-invoker/",
- "keywords": [
- "process"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
- "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
- "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:07:44+00:00"
- },
- {
- "name": "phpunit/php-text-template",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
- "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "4.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Simple template engine.",
- "homepage": "https://github.com/sebastianbergmann/php-text-template/",
- "keywords": [
- "template"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
- "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
- "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:08:43+00:00"
- },
- {
- "name": "phpunit/php-timer",
- "version": "7.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
- "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "7.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Utility class for timing",
- "homepage": "https://github.com/sebastianbergmann/php-timer/",
- "keywords": [
- "timer"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/php-timer/issues",
- "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
- "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:09:35+00:00"
- },
- {
- "name": "phpunit/phpunit",
- "version": "11.4.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "7875627f15f4da7e7f0823d1f323f7295a77334e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7875627f15f4da7e7f0823d1f323f7295a77334e",
- "reference": "7875627f15f4da7e7f0823d1f323f7295a77334e",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "ext-json": "*",
- "ext-libxml": "*",
- "ext-mbstring": "*",
- "ext-xml": "*",
- "ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.12.0",
- "phar-io/manifest": "^2.0.4",
- "phar-io/version": "^3.2.1",
- "php": ">=8.2",
- "phpunit/php-code-coverage": "^11.0.6",
- "phpunit/php-file-iterator": "^5.1.0",
- "phpunit/php-invoker": "^5.0.1",
- "phpunit/php-text-template": "^4.0.1",
- "phpunit/php-timer": "^7.0.1",
- "sebastian/cli-parser": "^3.0.2",
- "sebastian/code-unit": "^3.0.1",
- "sebastian/comparator": "^6.1.0",
- "sebastian/diff": "^6.0.2",
- "sebastian/environment": "^7.2.0",
- "sebastian/exporter": "^6.1.3",
- "sebastian/global-state": "^7.0.2",
- "sebastian/object-enumerator": "^6.0.1",
- "sebastian/type": "^5.1.0",
- "sebastian/version": "^5.0.1"
- },
- "suggest": {
- "ext-soap": "To be able to generate mocks based on WSDL files"
- },
- "bin": [
- "phpunit"
- ],
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "11.4-dev"
- }
- },
- "autoload": {
- "files": [
- "src/Framework/Assert/Functions.php"
- ],
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "The PHP Unit Testing framework.",
- "homepage": "https://phpunit.de/",
- "keywords": [
- "phpunit",
- "testing",
- "xunit"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/phpunit/issues",
- "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.1"
- },
- "funding": [
- {
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
- }
- ],
- "time": "2024-10-08T15:38:37+00:00"
- },
- {
- "name": "psr/container",
- "version": "2.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/container.git",
- "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
- "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
- "shasum": ""
- },
- "require": {
- "php": ">=7.4.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Container\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common Container Interface (PHP FIG PSR-11)",
- "homepage": "https://github.com/php-fig/container",
- "keywords": [
- "PSR-11",
- "container",
- "container-interface",
- "container-interop",
- "psr"
- ],
- "support": {
- "issues": "https://github.com/php-fig/container/issues",
- "source": "https://github.com/php-fig/container/tree/2.0.2"
- },
- "time": "2021-11-05T16:47:00+00:00"
- },
- {
- "name": "psr/event-dispatcher",
- "version": "1.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/event-dispatcher.git",
- "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
- "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\EventDispatcher\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "http://www.php-fig.org/"
- }
- ],
- "description": "Standard interfaces for event handling.",
- "keywords": [
- "events",
- "psr",
- "psr-14"
- ],
- "support": {
- "issues": "https://github.com/php-fig/event-dispatcher/issues",
- "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
- },
- "time": "2019-01-08T18:20:26+00:00"
- },
- {
- "name": "psr/http-client",
- "version": "1.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-client.git",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
- "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
- "shasum": ""
- },
- "require": {
- "php": "^7.0 || ^8.0",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Client\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP clients",
- "homepage": "https://github.com/php-fig/http-client",
- "keywords": [
- "http",
- "http-client",
- "psr",
- "psr-18"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-client"
- },
- "time": "2023-09-23T14:17:50+00:00"
- },
- {
- "name": "psr/http-factory",
- "version": "1.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-factory.git",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
- "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
- "shasum": ""
- },
- "require": {
- "php": ">=7.1",
- "psr/http-message": "^1.0 || ^2.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
- "keywords": [
- "factory",
- "http",
- "message",
- "psr",
- "psr-17",
- "psr-7",
- "request",
- "response"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-factory"
- },
- "time": "2024-04-15T12:06:14+00:00"
- },
- {
- "name": "psr/http-message",
- "version": "2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/http-message.git",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
- "shasum": ""
- },
- "require": {
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Http\\Message\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for HTTP messages",
- "homepage": "https://github.com/php-fig/http-message",
- "keywords": [
- "http",
- "http-message",
- "psr",
- "psr-7",
- "request",
- "response"
- ],
- "support": {
- "source": "https://github.com/php-fig/http-message/tree/2.0"
- },
- "time": "2023-04-04T09:54:51+00:00"
- },
- {
- "name": "psr/log",
- "version": "3.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/php-fig/log.git",
- "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
- "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
- "shasum": ""
- },
- "require": {
- "php": ">=8.0.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Psr\\Log\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "PHP-FIG",
- "homepage": "https://www.php-fig.org/"
- }
- ],
- "description": "Common interface for logging libraries",
- "homepage": "https://github.com/php-fig/log",
- "keywords": [
- "log",
- "psr",
- "psr-3"
- ],
- "support": {
- "source": "https://github.com/php-fig/log/tree/3.0.2"
- },
- "time": "2024-09-11T13:17:53+00:00"
- },
- {
- "name": "ralouphie/getallheaders",
- "version": "3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/ralouphie/getallheaders.git",
- "reference": "120b605dfeb996808c31b6477290a714d356e822"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
- "reference": "120b605dfeb996808c31b6477290a714d356e822",
- "shasum": ""
- },
- "require": {
- "php": ">=5.6"
- },
- "require-dev": {
- "php-coveralls/php-coveralls": "^2.1",
- "phpunit/phpunit": "^5 || ^6.5"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/getallheaders.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Ralph Khattar",
- "email": "ralph.khattar@gmail.com"
- }
- ],
- "description": "A polyfill for getallheaders.",
- "support": {
- "issues": "https://github.com/ralouphie/getallheaders/issues",
- "source": "https://github.com/ralouphie/getallheaders/tree/develop"
- },
- "time": "2019-03-08T08:55:37+00:00"
- },
- {
- "name": "react/cache",
- "version": "v1.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/cache.git",
- "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
- "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3.0",
- "react/promise": "^3.0 || ^2.0 || ^1.1"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\Cache\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "Async, Promise-based cache interface for ReactPHP",
- "keywords": [
- "cache",
- "caching",
- "promise",
- "reactphp"
- ],
- "support": {
- "issues": "https://github.com/reactphp/cache/issues",
- "source": "https://github.com/reactphp/cache/tree/v1.2.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2022-11-30T15:59:55+00:00"
- },
- {
- "name": "react/child-process",
- "version": "v0.6.5",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/child-process.git",
- "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43",
- "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43",
- "shasum": ""
- },
- "require": {
- "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
- "php": ">=5.3.0",
- "react/event-loop": "^1.2",
- "react/stream": "^1.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35",
- "react/socket": "^1.8",
- "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\ChildProcess\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "Event-driven library for executing child processes with ReactPHP.",
- "keywords": [
- "event-driven",
- "process",
- "reactphp"
- ],
- "support": {
- "issues": "https://github.com/reactphp/child-process/issues",
- "source": "https://github.com/reactphp/child-process/tree/v0.6.5"
- },
- "funding": [
- {
- "url": "https://github.com/WyriHaximus",
- "type": "github"
- },
- {
- "url": "https://github.com/clue",
- "type": "github"
- }
- ],
- "time": "2022-09-16T13:41:56+00:00"
- },
- {
- "name": "react/dns",
- "version": "v1.13.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/dns.git",
- "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5",
- "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3.0",
- "react/cache": "^1.0 || ^0.6 || ^0.5",
- "react/event-loop": "^1.2",
- "react/promise": "^3.2 || ^2.7 || ^1.2.1"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
- "react/async": "^4.3 || ^3 || ^2",
- "react/promise-timer": "^1.11"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\Dns\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "Async DNS resolver for ReactPHP",
- "keywords": [
- "async",
- "dns",
- "dns-resolver",
- "reactphp"
- ],
- "support": {
- "issues": "https://github.com/reactphp/dns/issues",
- "source": "https://github.com/reactphp/dns/tree/v1.13.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2024-06-13T14:18:03+00:00"
- },
- {
- "name": "react/event-loop",
- "version": "v1.5.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/event-loop.git",
- "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354",
- "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
- },
- "suggest": {
- "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\EventLoop\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
- "keywords": [
- "asynchronous",
- "event-loop"
- ],
- "support": {
- "issues": "https://github.com/reactphp/event-loop/issues",
- "source": "https://github.com/reactphp/event-loop/tree/v1.5.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2023-11-13T13:48:05+00:00"
- },
- {
- "name": "react/promise",
- "version": "v3.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/promise.git",
- "reference": "8a164643313c71354582dc850b42b33fa12a4b63"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63",
- "reference": "8a164643313c71354582dc850b42b33fa12a4b63",
- "shasum": ""
- },
- "require": {
- "php": ">=7.1.0"
- },
- "require-dev": {
- "phpstan/phpstan": "1.10.39 || 1.4.10",
- "phpunit/phpunit": "^9.6 || ^7.5"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions_include.php"
- ],
- "psr-4": {
- "React\\Promise\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "A lightweight implementation of CommonJS Promises/A for PHP",
- "keywords": [
- "promise",
- "promises"
- ],
- "support": {
- "issues": "https://github.com/reactphp/promise/issues",
- "source": "https://github.com/reactphp/promise/tree/v3.2.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2024-05-24T10:39:05+00:00"
- },
- {
- "name": "react/socket",
- "version": "v1.16.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/socket.git",
- "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1",
- "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1",
- "shasum": ""
- },
- "require": {
- "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
- "php": ">=5.3.0",
- "react/dns": "^1.13",
- "react/event-loop": "^1.2",
- "react/promise": "^3.2 || ^2.6 || ^1.2.1",
- "react/stream": "^1.4"
- },
- "require-dev": {
- "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
- "react/async": "^4.3 || ^3.3 || ^2",
- "react/promise-stream": "^1.4",
- "react/promise-timer": "^1.11"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\Socket\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
- "keywords": [
- "Connection",
- "Socket",
- "async",
- "reactphp",
- "stream"
- ],
- "support": {
- "issues": "https://github.com/reactphp/socket/issues",
- "source": "https://github.com/reactphp/socket/tree/v1.16.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2024-07-26T10:38:09+00:00"
- },
- {
- "name": "react/stream",
- "version": "v1.4.0",
- "source": {
- "type": "git",
- "url": "https://github.com/reactphp/stream.git",
- "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
- "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
- "shasum": ""
- },
- "require": {
- "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
- "php": ">=5.3.8",
- "react/event-loop": "^1.2"
- },
- "require-dev": {
- "clue/stream-filter": "~1.2",
- "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "React\\Stream\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering",
- "homepage": "https://clue.engineering/"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "reactphp@ceesjankiewiet.nl",
- "homepage": "https://wyrihaximus.net/"
- },
- {
- "name": "Jan Sorgalla",
- "email": "jsorgalla@gmail.com",
- "homepage": "https://sorgalla.com/"
- },
- {
- "name": "Chris Boden",
- "email": "cboden@gmail.com",
- "homepage": "https://cboden.dev/"
- }
- ],
- "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
- "keywords": [
- "event-driven",
- "io",
- "non-blocking",
- "pipe",
- "reactphp",
- "readable",
- "stream",
- "writable"
- ],
- "support": {
- "issues": "https://github.com/reactphp/stream/issues",
- "source": "https://github.com/reactphp/stream/tree/v1.4.0"
- },
- "funding": [
- {
- "url": "https://opencollective.com/reactphp",
- "type": "open_collective"
- }
- ],
- "time": "2024-06-11T12:45:25+00:00"
- },
- {
- "name": "sebastian/cli-parser",
- "version": "3.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180",
- "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Library for parsing CLI options",
- "homepage": "https://github.com/sebastianbergmann/cli-parser",
- "support": {
- "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
- "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:41:36+00:00"
- },
- {
- "name": "sebastian/code-unit",
- "version": "3.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit.git",
- "reference": "6bb7d09d6623567178cf54126afa9c2310114268"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6bb7d09d6623567178cf54126afa9c2310114268",
- "reference": "6bb7d09d6623567178cf54126afa9c2310114268",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Collection of value objects that represent the PHP code units",
- "homepage": "https://github.com/sebastianbergmann/code-unit",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit/issues",
- "security": "https://github.com/sebastianbergmann/code-unit/security/policy",
- "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:44:28+00:00"
- },
- {
- "name": "sebastian/code-unit-reverse-lookup",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "183a9b2632194febd219bb9246eee421dad8d45e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e",
- "reference": "183a9b2632194febd219bb9246eee421dad8d45e",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "4.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Looks up which function or method a line of code belongs to",
- "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
- "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy",
- "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:45:54+00:00"
- },
- {
- "name": "sebastian/comparator",
- "version": "6.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa37b9e2ca618cb051d71b60120952ee8ca8b03d",
- "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "ext-mbstring": "*",
- "php": ">=8.2",
- "sebastian/diff": "^6.0",
- "sebastian/exporter": "^6.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.3"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "6.1-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- },
- {
- "name": "Jeff Welch",
- "email": "whatthejeff@gmail.com"
- },
- {
- "name": "Volker Dusch",
- "email": "github@wallbash.com"
- },
- {
- "name": "Bernhard Schussek",
- "email": "bschussek@2bepublished.at"
- }
- ],
- "description": "Provides the functionality to compare PHP values for equality",
- "homepage": "https://github.com/sebastianbergmann/comparator",
- "keywords": [
- "comparator",
- "compare",
- "equality"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/comparator/issues",
- "security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/6.1.0"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-09-11T15:42:56+00:00"
- },
- {
- "name": "sebastian/complexity",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "ee41d384ab1906c68852636b6de493846e13e5a0"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0",
- "reference": "ee41d384ab1906c68852636b6de493846e13e5a0",
- "shasum": ""
- },
- "require": {
- "nikic/php-parser": "^5.0",
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "4.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Library for calculating the complexity of PHP code units",
- "homepage": "https://github.com/sebastianbergmann/complexity",
- "support": {
- "issues": "https://github.com/sebastianbergmann/complexity/issues",
- "security": "https://github.com/sebastianbergmann/complexity/security/policy",
- "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:49:50+00:00"
- },
- {
- "name": "sebastian/diff",
- "version": "6.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544",
- "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0",
- "symfony/process": "^4.2 || ^5"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "6.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- },
- {
- "name": "Kore Nordmann",
- "email": "mail@kore-nordmann.de"
- }
- ],
- "description": "Diff implementation",
- "homepage": "https://github.com/sebastianbergmann/diff",
- "keywords": [
- "diff",
- "udiff",
- "unidiff",
- "unified diff"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/diff/issues",
- "security": "https://github.com/sebastianbergmann/diff/security/policy",
- "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:53:05+00:00"
- },
- {
- "name": "sebastian/environment",
- "version": "7.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
- "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "suggest": {
- "ext-posix": "*"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "7.2-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Provides functionality to handle HHVM/PHP environments",
- "homepage": "https://github.com/sebastianbergmann/environment",
- "keywords": [
- "Xdebug",
- "environment",
- "hhvm"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/environment/issues",
- "security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:54:44+00:00"
- },
- {
- "name": "sebastian/exporter",
- "version": "6.1.3",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e",
- "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e",
- "shasum": ""
- },
- "require": {
- "ext-mbstring": "*",
- "php": ">=8.2",
- "sebastian/recursion-context": "^6.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.2"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "6.1-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- },
- {
- "name": "Jeff Welch",
- "email": "whatthejeff@gmail.com"
- },
- {
- "name": "Volker Dusch",
- "email": "github@wallbash.com"
- },
- {
- "name": "Adam Harvey",
- "email": "aharvey@php.net"
- },
- {
- "name": "Bernhard Schussek",
- "email": "bschussek@gmail.com"
- }
- ],
- "description": "Provides the functionality to export PHP variables for visualization",
- "homepage": "https://www.github.com/sebastianbergmann/exporter",
- "keywords": [
- "export",
- "exporter"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/exporter/issues",
- "security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/6.1.3"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:56:19+00:00"
- },
- {
- "name": "sebastian/global-state",
- "version": "7.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "3be331570a721f9a4b5917f4209773de17f747d7"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7",
- "reference": "3be331570a721f9a4b5917f4209773de17f747d7",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "sebastian/object-reflector": "^4.0",
- "sebastian/recursion-context": "^6.0"
- },
- "require-dev": {
- "ext-dom": "*",
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "7.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Snapshotting of global state",
- "homepage": "https://www.github.com/sebastianbergmann/global-state",
- "keywords": [
- "global state"
- ],
- "support": {
- "issues": "https://github.com/sebastianbergmann/global-state/issues",
- "security": "https://github.com/sebastianbergmann/global-state/security/policy",
- "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:57:36+00:00"
- },
- {
- "name": "sebastian/lines-of-code",
- "version": "3.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a",
- "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a",
- "shasum": ""
- },
- "require": {
- "nikic/php-parser": "^5.0",
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Library for counting the lines of code in PHP source code",
- "homepage": "https://github.com/sebastianbergmann/lines-of-code",
- "support": {
- "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
- "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T04:58:38+00:00"
- },
- {
- "name": "sebastian/object-enumerator",
- "version": "6.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "f5b498e631a74204185071eb41f33f38d64608aa"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa",
- "reference": "f5b498e631a74204185071eb41f33f38d64608aa",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "sebastian/object-reflector": "^4.0",
- "sebastian/recursion-context": "^6.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "6.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Traverses array structures and object graphs to enumerate all referenced objects",
- "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
- "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
- "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:00:13+00:00"
- },
- {
- "name": "sebastian/object-reflector",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9",
- "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "4.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Allows reflection of object attributes, including inherited and non-public ones",
- "homepage": "https://github.com/sebastianbergmann/object-reflector/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
- "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
- "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:01:32+00:00"
- },
- {
- "name": "sebastian/recursion-context",
- "version": "6.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "694d156164372abbd149a4b85ccda2e4670c0e16"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16",
- "reference": "694d156164372abbd149a4b85ccda2e4670c0e16",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "6.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- },
- {
- "name": "Jeff Welch",
- "email": "whatthejeff@gmail.com"
- },
- {
- "name": "Adam Harvey",
- "email": "aharvey@php.net"
- }
- ],
- "description": "Provides functionality to recursively process PHP variables",
- "homepage": "https://github.com/sebastianbergmann/recursion-context",
- "support": {
- "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-07-03T05:10:34+00:00"
- },
- {
- "name": "sebastian/type",
- "version": "5.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/type.git",
- "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac",
- "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.3"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "5.1-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Collection of value objects that represent the types of the PHP type system",
- "homepage": "https://github.com/sebastianbergmann/type",
- "support": {
- "issues": "https://github.com/sebastianbergmann/type/issues",
- "security": "https://github.com/sebastianbergmann/type/security/policy",
- "source": "https://github.com/sebastianbergmann/type/tree/5.1.0"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-09-17T13:12:04+00:00"
- },
- {
- "name": "sebastian/version",
- "version": "5.0.2",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/version.git",
- "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874",
- "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "5.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Library that helps with managing the version number of Git-hosted PHP projects",
- "homepage": "https://github.com/sebastianbergmann/version",
- "support": {
- "issues": "https://github.com/sebastianbergmann/version/issues",
- "security": "https://github.com/sebastianbergmann/version/security/policy",
- "source": "https://github.com/sebastianbergmann/version/tree/5.0.2"
- },
- "funding": [
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2024-10-09T05:16:32+00:00"
- },
- {
- "name": "squizlabs/php_codesniffer",
- "version": "3.10.3",
- "source": {
- "type": "git",
- "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
- "reference": "62d32998e820bddc40f99f8251958aed187a5c9c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c",
- "reference": "62d32998e820bddc40f99f8251958aed187a5c9c",
- "shasum": ""
- },
- "require": {
- "ext-simplexml": "*",
- "ext-tokenizer": "*",
- "ext-xmlwriter": "*",
- "php": ">=5.4.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
- },
- "bin": [
- "bin/phpcbf",
- "bin/phpcs"
- ],
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Greg Sherwood",
- "role": "Former lead"
- },
- {
- "name": "Juliette Reinders Folmer",
- "role": "Current lead"
- },
- {
- "name": "Contributors",
- "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
- }
- ],
- "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
- "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
- "keywords": [
- "phpcs",
- "standards",
- "static analysis"
- ],
- "support": {
- "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
- "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
- "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
- "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
- },
- "funding": [
- {
- "url": "https://github.com/PHPCSStandards",
- "type": "github"
- },
- {
- "url": "https://github.com/jrfnl",
- "type": "github"
- },
- {
- "url": "https://opencollective.com/php_codesniffer",
- "type": "open_collective"
- }
- ],
- "time": "2024-09-18T10:38:58+00:00"
- },
- {
- "name": "symfony/console",
- "version": "v7.1.5",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/console.git",
- "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/0fa539d12b3ccf068a722bbbffa07ca7079af9ee",
- "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/polyfill-mbstring": "~1.0",
- "symfony/service-contracts": "^2.5|^3",
- "symfony/string": "^6.4|^7.0"
- },
- "conflict": {
- "symfony/dependency-injection": "<6.4",
- "symfony/dotenv": "<6.4",
- "symfony/event-dispatcher": "<6.4",
- "symfony/lock": "<6.4",
- "symfony/process": "<6.4"
- },
- "provide": {
- "psr/log-implementation": "1.0|2.0|3.0"
- },
- "require-dev": {
- "psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/lock": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Console\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Eases the creation of beautiful and testable command line interfaces",
- "homepage": "https://symfony.com",
- "keywords": [
- "cli",
- "command-line",
- "console",
- "terminal"
- ],
- "support": {
- "source": "https://github.com/symfony/console/tree/v7.1.5"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-20T08:28:38+00:00"
- },
- {
- "name": "symfony/deprecation-contracts",
- "version": "v3.5.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
- "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.5-dev"
- },
- "thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
- }
- },
- "autoload": {
- "files": [
- "function.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "A generic function and convention to trigger deprecation notices",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-04-18T09:32:20+00:00"
- },
- {
- "name": "symfony/event-dispatcher",
- "version": "v7.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7",
- "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/event-dispatcher-contracts": "^2.5|^3"
- },
- "conflict": {
- "symfony/dependency-injection": "<6.4",
- "symfony/service-contracts": "<2.5"
- },
- "provide": {
- "psr/event-dispatcher-implementation": "1.0",
- "symfony/event-dispatcher-implementation": "2.0|3.0"
- },
- "require-dev": {
- "psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/service-contracts": "^2.5|^3",
- "symfony/stopwatch": "^6.4|^7.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\EventDispatcher\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v7.1.1"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-05-31T14:57:53+00:00"
- },
- {
- "name": "symfony/event-dispatcher-contracts",
- "version": "v3.5.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50",
- "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1",
- "psr/event-dispatcher": "^1"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.5-dev"
- },
- "thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
- }
- },
- "autoload": {
- "psr-4": {
- "Symfony\\Contracts\\EventDispatcher\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Generic abstractions related to dispatching event",
- "homepage": "https://symfony.com",
- "keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
- ],
- "support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-04-18T09:32:20+00:00"
- },
- {
- "name": "symfony/filesystem",
- "version": "v7.1.5",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/filesystem.git",
- "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/61fe0566189bf32e8cfee78335d8776f64a66f5a",
- "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-mbstring": "~1.8"
- },
- "require-dev": {
- "symfony/process": "^6.4|^7.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Filesystem\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides basic utilities for the filesystem",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/filesystem/tree/v7.1.5"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-17T09:16:35+00:00"
- },
- {
- "name": "symfony/finder",
- "version": "v7.1.4",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/finder.git",
- "reference": "d95bbf319f7d052082fb7af147e0f835a695e823"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823",
- "reference": "d95bbf319f7d052082fb7af147e0f835a695e823",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "symfony/filesystem": "^6.4|^7.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Finder\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Finds files and directories via an intuitive fluent interface",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/finder/tree/v7.1.4"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-08-13T14:28:19+00:00"
- },
- {
- "name": "symfony/options-resolver",
- "version": "v7.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/options-resolver.git",
- "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/47aa818121ed3950acd2b58d1d37d08a94f9bf55",
- "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\OptionsResolver\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides an improved replacement for the array_replace PHP function",
- "homepage": "https://symfony.com",
- "keywords": [
- "config",
- "configuration",
- "options"
- ],
- "support": {
- "source": "https://github.com/symfony/options-resolver/tree/v7.1.1"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-05-31T14:57:53+00:00"
- },
- {
- "name": "symfony/polyfill-ctype",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "provide": {
- "ext-ctype": "*"
- },
- "suggest": {
- "ext-ctype": "For best performance"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Ctype\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Gert de Pagter",
- "email": "BackEndTea@gmail.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill for ctype functions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "ctype",
- "polyfill",
- "portable"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-intl-grapheme",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
- "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "suggest": {
- "ext-intl": "For best performance"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill for intl's grapheme_* functions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "grapheme",
- "intl",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-intl-normalizer",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "suggest": {
- "ext-intl": "For best performance"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill for intl's Normalizer class and related functions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "intl",
- "normalizer",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-mbstring",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
- "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "provide": {
- "ext-mbstring": "*"
- },
- "suggest": {
- "ext-mbstring": "For best performance"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Mbstring\\": ""
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill for the Mbstring extension",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "mbstring",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-php80",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
- "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php80\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Ion Bazan",
- "email": "ion.bazan@gmail.com"
- },
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/polyfill-php81",
- "version": "v1.31.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-php81.git",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php81\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-09T11:45:10+00:00"
- },
- {
- "name": "symfony/process",
- "version": "v7.1.5",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/process.git",
- "reference": "5c03ee6369281177f07f7c68252a280beccba847"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847",
- "reference": "5c03ee6369281177f07f7c68252a280beccba847",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Process\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Executes commands in sub-processes",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/process/tree/v7.1.5"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-19T21:48:23+00:00"
- },
- {
- "name": "symfony/service-contracts",
- "version": "v3.5.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/service-contracts.git",
- "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
- "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1",
- "psr/container": "^1.1|^2.0",
- "symfony/deprecation-contracts": "^2.5|^3"
- },
- "conflict": {
- "ext-psr": "<1.1|>=2"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.5-dev"
- },
- "thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
- }
- },
- "autoload": {
- "psr-4": {
- "Symfony\\Contracts\\Service\\": ""
- },
- "exclude-from-classmap": [
- "/Test/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Generic abstractions related to writing services",
- "homepage": "https://symfony.com",
- "keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
- ],
- "support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.5.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-04-18T09:32:20+00:00"
- },
- {
- "name": "symfony/stopwatch",
- "version": "v7.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/stopwatch.git",
- "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d",
- "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/service-contracts": "^2.5|^3"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Stopwatch\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides a way to profile code",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/stopwatch/tree/v7.1.1"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-05-31T14:57:53+00:00"
- },
- {
- "name": "symfony/string",
- "version": "v7.1.5",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/string.git",
- "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306",
- "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
- "symfony/polyfill-intl-normalizer": "~1.0",
- "symfony/polyfill-mbstring": "~1.0"
- },
- "conflict": {
- "symfony/translation-contracts": "<2.5"
- },
- "require-dev": {
- "symfony/emoji": "^7.1",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
- "symfony/translation-contracts": "^2.5|^3.0",
- "symfony/var-exporter": "^6.4|^7.0"
- },
- "type": "library",
- "autoload": {
- "files": [
- "Resources/functions.php"
- ],
- "psr-4": {
- "Symfony\\Component\\String\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
- "homepage": "https://symfony.com",
- "keywords": [
- "grapheme",
- "i18n",
- "string",
- "unicode",
- "utf-8",
- "utf8"
- ],
- "support": {
- "source": "https://github.com/symfony/string/tree/v7.1.5"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-20T08:28:38+00:00"
- },
- {
- "name": "symfony/yaml",
- "version": "v7.1.5",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/yaml.git",
- "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/4e561c316e135e053bd758bf3b3eb291d9919de4",
- "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2",
- "symfony/polyfill-ctype": "^1.8"
- },
- "conflict": {
- "symfony/console": "<6.4"
- },
- "require-dev": {
- "symfony/console": "^6.4|^7.0"
- },
- "bin": [
- "Resources/bin/yaml-lint"
- ],
- "type": "library",
- "autoload": {
- "psr-4": {
- "Symfony\\Component\\Yaml\\": ""
- },
- "exclude-from-classmap": [
- "/Tests/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Loads and dumps YAML files",
- "homepage": "https://symfony.com",
- "support": {
- "source": "https://github.com/symfony/yaml/tree/v7.1.5"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2024-09-17T12:49:58+00:00"
- },
- {
- "name": "theseer/tokenizer",
- "version": "1.2.3",
- "source": {
- "type": "git",
- "url": "https://github.com/theseer/tokenizer.git",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
- "shasum": ""
- },
- "require": {
- "ext-dom": "*",
- "ext-tokenizer": "*",
- "ext-xmlwriter": "*",
- "php": "^7.2 || ^8.0"
- },
- "type": "library",
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Arne Blankerts",
- "email": "arne@blankerts.de",
- "role": "Developer"
- }
- ],
- "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
- "support": {
- "issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
- },
- "funding": [
- {
- "url": "https://github.com/theseer",
- "type": "github"
- }
- ],
- "time": "2024-03-03T12:36:25+00:00"
- }
- ],
+ "content-hash": "dbf582b281a716bd010895a27508485f",
+ "packages": [],
+ "packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": [],
- "prefer-stable": false,
+ "stability-flags": {},
+ "prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": "^8.3"
+ "php": "^8.4"
},
- "platform-dev": [],
+ "platform-dev": {},
"plugin-api-version": "2.6.0"
}
diff --git a/create_structure.php b/create_structure.php
deleted file mode 100644
index 9262398..0000000
--- a/create_structure.php
+++ /dev/null
@@ -1,46 +0,0 @@
- ['DotenvTest.php'],
- 'tests/Unit/Loader' => ['ArrayLoaderTest.php', 'FileLoaderTest.php'],
- 'tests/Unit/Parser' => ['DefaultParserTest.php', 'StrictParserTest.php'],
- 'tests/Unit/Type/Caster' => [
- 'ArrayCasterTest.php',
- 'BooleanCasterTest.php',
- 'FloatCasterTest.php',
- 'IntegerCasterTest.php',
- 'JsonCasterTest.php',
- 'NullCasterTest.php',
- 'StringCasterTest.php',
- 'TypeCasterRegistryTest.php'
- ],
- 'tests/Unit/Type/Detector' => [
- 'ArrayDetectorTest.php',
- 'BooleanDetectorTest.php',
- 'JsonDetectorTest.php',
- 'NullDetectorTest.php',
- 'NumericDetectorTest.php',
- 'StringDetectorTest.php',
- 'TypeDetectorRegistryTest.php'
- ],
- 'tests/Unit' => ['DotenvTest.php', 'DotenvFactoryTest.php'],
- 'tests/Integration' => ['DotenvIntegrationTest.php', 'TypeSystemIntegrationTest.php'],
- 'tests/Functional' => ['DotenvFunctionalTest.php']
-];
-
-foreach ($structure as $dir => $files) {
- // Cria o diretório, caso não exista
- if (!is_dir($dir)) {
- mkdir($dir, 0777, true);
- echo "Diretório criado: $dir\n";
- }
-
- // Cria os arquivos dentro do diretório
- foreach ($files as $file) {
- $filePath = $dir . '/' . $file;
- file_put_contents($filePath, "strictNames,
+ // ... named arguments for all parameters
+ );
+ }
+}
+```
+
+Each `with*()` method returns a new instance using named arguments, ensuring:
+1. No parameter ordering bugs — named args are position-independent.
+2. Adding a new constructor parameter requires no changes to existing `with*()` methods (if a default is provided).
+3. The original instance is never modified.
+
+### EnvironmentVariable
+
+```php
+final readonly class EnvironmentVariable
+{
+ public function __construct(
+ public string $name,
+ public string $rawValue,
+ public ValueType $type,
+ public mixed $value,
+ public string $source = '',
+ public bool $overridden = false,
+ ) {}
+}
+```
+
+Once a variable is parsed, typed, and stored, its representation is sealed. The `overridden` flag records lineage without mutating prior state — the previous `EnvironmentVariable` is simply replaced in the `$variables` array.
+
+### Enums as Immutable Discriminators
+
+`LoadMode` and `ValueType` are backed-less PHP 8.4 enums — they carry identity without mutable backing values:
+
+```php
+enum LoadMode { case Immutable; case Overwrite; case SkipExisting; }
+enum ValueType { case String; case Integer; case Float; case Boolean; case Null; case Json; case Array; }
+```
+
+## Consequences
+
+**Positive:**
+- Thread-safe by construction — no locks needed in async runtimes.
+- Predictable behavior — configuration observed at time T₁ is identical at T₂.
+- IDE support — readonly properties enable aggressive static analysis (PHPStan level 9).
+- Defensive copying is unnecessary — readonly eliminates accidental mutation.
+
+**Negative:**
+- Configuration changes require instantiating new `DotenvConfiguration` objects via `with*()` methods.
+- Cannot use property hooks (PHP 8.4) on readonly promoted properties — a language-level constraint.
+
+**Trade-off accepted:** The cost of object allocation for `with*()` calls is negligible at bootstrap time (once per application lifecycle), making immutability effectively free.
diff --git a/docs/adr/ADR-003-type-system.md b/docs/adr/ADR-003-type-system.md
new file mode 100644
index 0000000..ec5659b
--- /dev/null
+++ b/docs/adr/ADR-003-type-system.md
@@ -0,0 +1,84 @@
+# ADR-003: Pluggable Type System with Priority-Based Detection
+
+**Status:** Accepted
+**Date:** 2024-01-20
+**Applies to:** KaririCode\Dotenv 4.0+
+
+## Context
+
+Environment variables are strings by definition (POSIX, IEEE Std 1003.1). However, application code universally needs typed values: booleans for feature flags, integers for ports, arrays for IP whitelists. The conversion from string to typed value involves two distinct operations:
+
+1. **Detection** — determining the semantic type of a string value.
+2. **Casting** — converting the string to the detected PHP type.
+
+These operations must be extensible (applications may define custom types) and deterministic (same input always produces the same type).
+
+## Decision
+
+### Two-Phase Architecture
+
+The `TypeSystem` orchestrates detection and casting as separate, composable phases:
+
+```
+Raw String → [Detector₁, Detector₂, ..., Detectorₙ] → ValueType → Caster → Typed Value
+```
+
+**Phase 1 — Detection:** Detectors are sorted by priority (descending). The first detector returning a non-null `ValueType` wins. If no detector matches, the value remains `ValueType::String`.
+
+**Phase 2 — Casting:** The `ValueType` selects the corresponding `TypeCaster`. `ValueType::String` short-circuits — no caster is invoked.
+
+### Priority Order
+
+| Detector | Priority | Rationale |
+|---|---|---|
+| NullDetector | 200 | `"null"` must not be detected as a string. |
+| BooleanDetector | 190 | `"true"`/`"false"` must not be detected as strings. |
+| IntegerDetector | 180 | `"42"` must be integer, not float. |
+| FloatDetector | 170 | `"3.14"` must contain `.` or `e` to avoid integer confusion. |
+| JsonDetector | 160 | `{...}` objects before array check. |
+| ArrayDetector | 150 | `[...]` arrays. |
+| *(String)* | *(fallback)* | Everything else. |
+
+The gap between priorities (10) allows custom detectors to be inserted at any position.
+
+### Contracts
+
+```php
+interface TypeDetector {
+ public function priority(): int;
+ public function detect(string $value): ?ValueType;
+}
+
+interface TypeCaster {
+ public function cast(string $value): mixed;
+}
+```
+
+### Lazy Sorting
+
+Detectors are sorted only when `detect()` is first called, and re-sorted only when `addDetector()` is called. A `$sorted` flag avoids redundant `usort()` calls.
+
+### Extension Point
+
+```php
+$dotenv->addTypeDetector(new SemVerDetector()); // Custom detector at any priority
+$dotenv->addTypeCaster(ValueType::Integer, new StrictIntCaster()); // Replace default
+```
+
+Adding a detector invalidates the sort. Adding a caster replaces the existing one for that `ValueType`.
+
+## Consequences
+
+**Positive:**
+- Open/Closed Principle — new types require no modification to `TypeSystem`.
+- Deterministic — priority ordering eliminates ambiguity (`"true"` is always boolean, never string).
+- Testable — each detector/caster is a pure function, testable in isolation.
+- Zero overhead for strings — fallback path invokes no caster.
+
+**Negative:**
+- Priority collisions: two detectors at the same priority have undefined ordering (stable sort, but insertion-order dependent).
+- No composite types: a value cannot be detected as multiple types simultaneously.
+
+**Mitigations:**
+- Document that custom detectors should use priorities not in the default range (100–200).
+- The `resolve()` convenience method chains detect + cast for the common case.
diff --git a/docs/adr/ADR-004-encryption-format.md b/docs/adr/ADR-004-encryption-format.md
new file mode 100644
index 0000000..8507179
--- /dev/null
+++ b/docs/adr/ADR-004-encryption-format.md
@@ -0,0 +1,89 @@
+# ADR-004: AES-256-GCM Encryption Format
+
+**Status:** Accepted
+**Date:** 2024-02-10
+**Applies to:** KaririCode\Dotenv 4.3+
+
+## Context
+
+Committing secrets to version control is the #1 source of credential leaks in modern applications. The traditional mitigation — external secret managers (Vault, AWS Secrets Manager, GCP KMS) — adds operational complexity and a runtime dependency on network availability. For many teams, the pragmatic solution is encrypting secrets directly in `.env` files, allowing them to be committed safely alongside application code.
+
+Requirements:
+1. Per-value encryption (not whole-file) — plaintext and encrypted values coexist.
+2. Authenticated encryption — ciphertext integrity must be verifiable (no silent corruption).
+3. No external tools or runtimes — pure PHP using bundled extensions.
+4. Unique ciphertext per encryption — same plaintext must produce different ciphertext each time.
+5. Self-describing format — encrypted values must be distinguishable from plaintext without metadata.
+
+## Decision
+
+### Algorithm: AES-256-GCM
+
+AES-256-GCM is an AEAD (Authenticated Encryption with Associated Data) cipher that provides:
+- **Confidentiality:** 256-bit AES encryption.
+- **Integrity:** 128-bit GCM authentication tag detects tampering.
+- **Performance:** Hardware-accelerated via AES-NI on modern CPUs.
+
+PHP's ext-openssl (bundled in standard distributions) provides `openssl_encrypt()` / `openssl_decrypt()` with native GCM support.
+
+### Wire Format
+
+```
+encrypted:
+```
+
+| Field | Size | Description |
+|---|---|---|
+| Prefix | 10 bytes | ASCII literal `encrypted:` — enables `str_starts_with()` detection. |
+| Nonce | 12 bytes | Random IV per encryption. GCM requires exactly 96 bits. |
+| Ciphertext | variable | AES-256-GCM output. 0 bytes for empty plaintext. |
+| Tag | 16 bytes | GCM authentication tag (128 bits). |
+
+The entire binary payload (nonce + ciphertext + tag) is base64-encoded into a single string.
+
+### Key Format
+
+Keys are 256-bit (32 bytes) values. Two representations are accepted:
+- **Hex string:** 64 hexadecimal characters (e.g., `a1b2c3d4...`). Stored in `DOTENV_PRIVATE_KEY`.
+- **Raw binary:** 32 bytes. Used internally after hex decoding.
+
+The `KeyPair` class generates keys via `random_bytes(32)` and derives an 8-character public ID from `sha256(raw_key)[0:8]` for multi-environment key management.
+
+### Encryption Flow
+
+```
+plaintext → random_bytes(12) → openssl_encrypt(AES-256-GCM) → "encrypted:" + base64(nonce ‖ ciphertext ‖ tag)
+```
+
+### Decryption Flow
+
+```
+"encrypted:..." → strip prefix → base64_decode → split(nonce, ciphertext, tag) → openssl_decrypt → plaintext
+```
+
+Non-encrypted values (`Encryptor::isEncrypted() === false`) pass through unchanged.
+
+### Security Properties
+
+1. **Nonce uniqueness:** `random_bytes(12)` ensures probabilistic uniqueness. Nonce reuse with the same key is cryptographically catastrophic for GCM; random generation provides 2⁹⁶ space.
+2. **Authentication:** GCM tag prevents bit-flipping and truncation attacks. `openssl_decrypt()` returns `false` on tag mismatch.
+3. **Key validation:** Constructor rejects keys that are not exactly 32 bytes after hex decoding.
+4. **No padding oracle:** GCM is a streaming mode — no padding, no padding oracle.
+
+## Consequences
+
+**Positive:**
+- Secrets can be safely committed to VCS — only the private key must be protected.
+- No runtime dependency on external services — decryption is local and instant.
+- Self-describing format — `encrypted:` prefix enables transparent decryption in the load pipeline.
+- Each encryption produces unique ciphertext — safe for audit logs and diff tools.
+
+**Negative:**
+- Key rotation requires re-encrypting all values. The CLI `encrypt` command handles this.
+- ext-openssl must be present. It is bundled in all standard PHP distributions but may be absent in Alpine-based minimal images.
+- No key derivation function (KDF) — the key must be high-entropy (random bytes), not a password.
+
+**Rejected alternatives:**
+- **libsodium (XChaCha20-Poly1305):** Stronger nonce safety (192-bit nonce) but ext-sodium is not bundled in all distributions. AES-256-GCM with random 96-bit nonces is safe for the expected volume (< 2³² encryptions per key).
+- **Whole-file encryption:** Would prevent `git diff` on plaintext values and require decrypting everything to read a single variable.
+- **Envelope encryption (AWS KMS pattern):** Adds network dependency and complexity disproportionate to the use case.
diff --git a/docs/adr/ADR-005-opcache-cache.md b/docs/adr/ADR-005-opcache-cache.md
new file mode 100644
index 0000000..b260069
--- /dev/null
+++ b/docs/adr/ADR-005-opcache-cache.md
@@ -0,0 +1,104 @@
+# ADR-005: OPcache-Friendly PHP Array Cache
+
+**Status:** Accepted
+**Date:** 2024-02-15
+**Applies to:** KaririCode\Dotenv 4.2+
+
+## Context
+
+In production, `.env` files are static — they change only during deployments. Parsing the same file on every request wastes CPU cycles. The ideal cache format should:
+
+1. Eliminate file I/O and string parsing entirely after first load.
+2. Leverage existing PHP infrastructure (no external cache servers).
+3. Be atomic — no partial reads during deployment.
+4. Self-invalidate when source files change.
+
+## Decision
+
+### Format: `var_export()` PHP Array
+
+The cache is a plain PHP file that returns an associative array:
+
+```php
+
+ array (
+ 'generated_at' => '2024-02-15T10:30:00+00:00',
+ 'source_hash' => 'a1b2c3d4e5f6...',
+ 'generator' => 'KaririCode\\Dotenv v4.x',
+ ),
+ 'DB_HOST' => 'localhost',
+ 'DB_PORT' => '5432',
+ 'APP_DEBUG' => 'true',
+);
+```
+
+When OPcache is enabled, PHP compiles this file once into shared memory. Subsequent `include` calls load directly from shared memory — no disk I/O, no parsing, no lexing. This is the fastest possible read path in PHP.
+
+### Atomic Writes
+
+```php
+$tmpFile = $path . '.tmp.' . getmypid();
+file_put_contents($tmpFile, $content);
+rename($tmpFile, $path);
+opcache_invalidate($path, true);
+```
+
+1. Write to a temporary file (PID-suffixed to avoid collisions).
+2. Atomic `rename()` — POSIX guarantees this is atomic on the same filesystem.
+3. Invalidate OPcache for the target path so the new version is picked up.
+
+### Staleness Detection
+
+The `__metadata.source_hash` is computed from file modification times and sizes of all source `.env` files:
+
+```php
+$context = hash_init('md5');
+foreach ($filePaths as $filePath) {
+ hash_update($context, (string) filemtime($filePath));
+ hash_update($context, (string) filesize($filePath));
+}
+return hash_final($context);
+```
+
+On load, if the stored hash doesn't match the current source hash, the cache is considered stale and bypassed. This avoids content hashing (expensive for large files) while still detecting changes.
+
+### Load Flow
+
+```
+load() called
+ → cachePath configured?
+ → No: parse .env files normally
+ → Yes: compute source hash → load cache file → hash matches?
+ → Yes: use cached variables (zero parse cost)
+ → No: parse .env files normally (cache is stale)
+```
+
+### Raw String Storage
+
+The cache stores **raw string values**, not typed values. Type detection and casting run after cache load, ensuring that custom detectors/casters registered at runtime are applied consistently regardless of whether the source was cache or file.
+
+## Consequences
+
+**Positive:**
+- Zero parse cost in production when OPcache is enabled — variables load from shared memory.
+- No external cache dependency (Redis, Memcached, APCu).
+- Atomic writes prevent partial reads during deployment.
+- Automatic staleness detection without manual cache clearing.
+- `var_export()` produces valid PHP that survives OPcache restarts.
+
+**Negative:**
+- Requires filesystem write access in the deployment step.
+- OPcache must be enabled for the full performance benefit (still faster than parsing without OPcache due to `include` vs string parsing).
+- MD5-based hash is not cryptographically secure — but it only needs collision resistance for cache invalidation, not security.
+
+**Rejected alternatives:**
+- **APCu cache:** Requires ext-apcu and doesn't survive OPcache resets.
+- **JSON cache:** Requires `json_decode()` on every request — slower than `include`.
+- **Serialized PHP:** `unserialize()` is slower than `include` for array data and has security implications.
+- **Content hashing (SHA-256 of file contents):** Correct but expensive — reading entire file contents to check if cache is valid defeats the purpose.
diff --git a/docs/adr/ADR-006-load-modes.md b/docs/adr/ADR-006-load-modes.md
new file mode 100644
index 0000000..32fbb07
--- /dev/null
+++ b/docs/adr/ADR-006-load-modes.md
@@ -0,0 +1,79 @@
+# ADR-006: Three Load Modes for Environment Variable Precedence
+
+**Status:** Accepted
+**Date:** 2024-01-20
+**Applies to:** KaririCode\Dotenv 4.0+
+
+## Context
+
+When a `.env` file declares `DB_HOST=localhost` but the operating system already has `DB_HOST=production-host` set via the container orchestrator, which value wins? The answer depends on the deployment model:
+
+- **Containerized (Kubernetes, ECS):** The orchestrator injects real secrets via environment variables. The `.env` file contains development defaults. Real environment must take precedence.
+- **Traditional server:** The `.env` file is the source of truth. No environment variables are pre-set.
+- **Development:** Developers want `.env` to override everything for local testing.
+
+A single behavior cannot satisfy all three models.
+
+## Decision
+
+Three mutually exclusive load modes, selected via `DotenvConfiguration::$loadMode`:
+
+### `LoadMode::Immutable` (Default)
+
+Throws `ImmutableException` if a `.env` variable conflicts with a pre-existing environment variable (one that existed **before** `load()` was called). Variables loaded by the Dotenv instance itself can be overridden across multiple files (e.g., `.env` → `.env.local`).
+
+**Rationale:** The safest default. If the container orchestrator set `DB_PASSWORD`, the `.env` file's value is almost certainly stale. Throwing an exception surfaces the conflict immediately rather than silently using the wrong value.
+
+**Scope of immutability:** Only variables that existed in `$_ENV`, `$_SERVER`, or `getenv()` before the Dotenv instance started loading are protected. Variables introduced by earlier `.env` files in the same load sequence are not protected — this allows cascade loading (`.env` → `.env.local`) to work naturally.
+
+### `LoadMode::Overwrite`
+
+Always overwrites, regardless of prior state. The `.env` file is the absolute source of truth.
+
+**Use case:** Development environments, test suites, and applications where the `.env` file is managed by deployment tooling (Ansible, Chef) and is the canonical source.
+
+### `LoadMode::SkipExisting`
+
+Silently skips any variable that already exists in the environment. No exception, no overwrite.
+
+**Use case:** Docker images that set sensible defaults in `.env` but allow runtime overrides via `docker run -e DB_HOST=...`. The `.env` provides fallbacks only.
+
+### Decision Matrix
+
+| Scenario | Pre-existing var | .env var | Immutable | Overwrite | SkipExisting |
+|---|---|---|---|---|---|
+| Conflict | `DB_HOST=prod` | `DB_HOST=dev` | ❌ throws | `dev` wins | `prod` wins |
+| No conflict | *(absent)* | `DB_HOST=dev` | `dev` set | `dev` set | `dev` set |
+| Cascade | `.env`: `X=a` | `.env.local`: `X=b` | `b` wins | `b` wins | `b` wins |
+
+### Implementation
+
+The check occurs in `Dotenv::setVariable()`:
+
+```php
+$alreadyExists = isset($_ENV[$name]) || isset($_SERVER[$name]) || getenv($name) !== false;
+
+if ($this->configuration->loadMode === LoadMode::Immutable
+ && $alreadyExists
+ && !isset($this->variables[$name]) // Not loaded by this instance
+) {
+ throw ImmutableException::alreadyDefined($name);
+}
+
+if ($this->configuration->loadMode === LoadMode::SkipExisting && $alreadyExists) {
+ return;
+}
+```
+
+The `!isset($this->variables[$name])` guard is critical — it distinguishes between "existed before Dotenv" (immutable violation) and "loaded by a prior .env file in this instance" (cascade override, allowed).
+
+## Consequences
+
+**Positive:**
+- Explicit control over precedence — no guessing which value is active.
+- Safe default (Immutable) prevents silent credential conflicts in production.
+- All three modes share the same code path — the only difference is the conflict resolution branch.
+
+**Negative:**
+- Immutable mode can cause startup failures if the deployment pipeline inadvertently sets variables that conflict with `.env`. This is by design — the failure surfaces the misconfiguration.
+- SkipExisting mode can silently mask stale `.env` values. Developers must understand that pre-existing environment variables always win.
diff --git a/docs/adr/ADR-007-environment-cascade.md b/docs/adr/ADR-007-environment-cascade.md
new file mode 100644
index 0000000..7b58a07
--- /dev/null
+++ b/docs/adr/ADR-007-environment-cascade.md
@@ -0,0 +1,91 @@
+# ADR-007: Environment-Aware Cascade Loading
+
+**Status:** Accepted
+**Date:** 2024-02-20
+**Applies to:** KaririCode\Dotenv 4.2+
+
+## Context
+
+Real-world applications operate across multiple environments: development, testing, staging, production. Each environment shares a common base configuration but overrides specific values. The traditional approach — maintaining separate `.env.production`, `.env.staging` files — leads to duplication and drift.
+
+A cascade loading strategy loads multiple files in a defined order, where later files override earlier ones. This allows:
+- Base defaults in `.env` (committed to VCS)
+- Local developer overrides in `.env.local` (gitignored)
+- Environment-specific values in `.env.{env}` (committed)
+- Environment-specific local overrides in `.env.{env}.local` (gitignored)
+
+## Decision
+
+### `bootEnv()` Method
+
+The `Dotenv::bootEnv()` method implements a four-layer cascade:
+
+```
+Layer 1: .env (base defaults, committed)
+Layer 2: .env.local (local overrides, gitignored)
+Layer 3: .env.{APP_ENV} (environment defaults, committed)
+Layer 4: .env.{APP_ENV}.local (environment local overrides, gitignored)
+```
+
+Each layer is loaded with `required: false` — missing files are silently skipped.
+
+### Environment Name Resolution
+
+The environment name is resolved in priority order:
+
+1. **Explicit parameter:** `$dotenv->bootEnv('production')` — highest priority.
+2. **Configuration:** `DotenvConfiguration::$environmentName`.
+3. **Loaded variable:** The `APP_ENV` variable from Layer 1 or Layer 2.
+4. **System environment:** `$_ENV['APP_ENV']` or `$_SERVER['APP_ENV']`.
+5. **Default:** `'dev'`.
+
+This order allows `.env` to declare `APP_ENV=staging` and have Layer 3 load `.env.staging` accordingly.
+
+### Test Environment Safety
+
+When the resolved environment is `test`, Layer 4 (`.env.test.local`) is **not loaded**. This ensures test suite reproducibility — local developer overrides must not affect CI/CD test runs.
+
+```php
+if ($envName !== 'test') {
+ $this->loadFile("{$basePath}.{$envName}.local", required: false);
+}
+```
+
+### Gitignore Convention
+
+The following `.gitignore` entries are recommended:
+
+```gitignore
+.env.local
+.env.*.local
+.env.cache.php
+```
+
+This keeps local overrides out of VCS while allowing `.env`, `.env.staging`, and `.env.production` to be committed as shared configuration.
+
+### Cache Integration
+
+`bootEnv()` checks for a cache file **before** any cascade loading. If the cache is fresh, no files are parsed. The cache should be dumped after the full cascade has been resolved (i.e., in the deployment pipeline).
+
+```php
+public function bootEnv(?string $environmentName = null): void
+{
+ if ($this->loadFromCache()) {
+ $this->loaded = true;
+ return;
+ }
+ // ... cascade loading
+}
+```
+
+## Consequences
+
+**Positive:**
+- Single `.env` committed to VCS with environment-specific overrides — no duplication.
+- Developers customize freely via `.env.local` without affecting teammates.
+- Test reproducibility guaranteed by skipping `.env.test.local`.
+- Compatible with cache: deploy pipeline dumps cache after cascade resolution.
+
+**Negative:**
+- Four-layer cascade can be confusing for debugging ("which file set this value?"). The `debug()` method mitigates this by reporting the source file for each variable.
+- Layer 2 (`.env.local`) loads before the environment is resolved, meaning it cannot be environment-specific. This is intentional — `.env.local` is for machine-specific overrides (paths, ports), not environment-specific logic.
diff --git a/docs/adr/ADR-008-validation-strategy.md b/docs/adr/ADR-008-validation-strategy.md
new file mode 100644
index 0000000..e4df66e
--- /dev/null
+++ b/docs/adr/ADR-008-validation-strategy.md
@@ -0,0 +1,134 @@
+# ADR-008: Batch Error Validation with Fluent DSL
+
+**Status:** Accepted
+**Date:** 2024-02-05
+**Applies to:** KaririCode\Dotenv 4.1+
+
+## Context
+
+Environment validation traditionally follows a fail-fast model: check the first variable, throw on failure, fix, re-run, discover the next failure. For applications with 20+ required variables, this creates a frustrating fix-one-discover-one loop that can require dozens of restart cycles during initial setup.
+
+Requirements:
+1. Collect **all** validation failures in a single pass.
+2. Provide a fluent API that reads like a specification.
+3. Support conditional validation (validate only if variable is present).
+4. Allow custom validation logic without subclassing.
+5. Separate validation definition from execution (define rules, then `assert()`).
+
+## Decision
+
+### Two-Phase Validation
+
+**Phase 1 — Rule Collection:** The fluent API collects rules without executing them. Each method returns `$this` for chaining.
+
+**Phase 2 — Assertion:** `assert()` executes all collected rules and throws a single `ValidationException` containing every failure message.
+
+```php
+$dotenv->validate()
+ ->required('DB_HOST', 'DB_PORT') // Phase 1: collect
+ ->isInteger('DB_PORT')->between(1, 65535)
+ ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ ->assert(); // Phase 2: execute all
+```
+
+### Error Collection
+
+```php
+public function assert(): void
+{
+ $errors = [];
+
+ // Check required presence first
+ foreach ($this->requiredNames as $name) {
+ if (($this->valueResolver)($name) === null) {
+ $errors[] = "{$name} is required but not defined.";
+ }
+ }
+
+ // Run rules per variable
+ foreach ($this->rules as $name => $ruleList) {
+ $value = ($this->valueResolver)($name);
+ if ($value === null) { continue; } // Skip absent (already reported or conditional)
+
+ foreach ($ruleList as $rule) {
+ if (!$rule->passes($value)) {
+ $errors[] = str_replace('{name}', $name, $rule->message());
+ }
+ }
+ }
+
+ if ($errors !== []) {
+ throw ValidationException::batchErrors($errors);
+ }
+}
+```
+
+The `ValidationException` carries both a human-readable message and a structured `errors(): array` for programmatic inspection.
+
+### Fluent Targeting
+
+The validator maintains a `$currentTargets` array that tracks which variables subsequent rules apply to. Targeting methods update this state:
+
+- `required('A', 'B')` — sets targets to `['A', 'B']`, marks as required.
+- `ifPresent('C')` — sets targets to `['C']`, enables conditional mode.
+- `isInteger('D')` — adds IsIntegerRule to `D`, sets target to `['D']`.
+- `between(1, 65535)` — adds BetweenRule to current targets (inherits from prior call).
+
+This allows natural chaining: `->isInteger('PORT')->between(1, 65535)` applies both rules to `PORT`.
+
+### Conditional Validation (`ifPresent`)
+
+Variables targeted via `ifPresent()` are tracked in a `$conditionalNames` map. During `assert()`, if a conditional variable is absent, its rules are silently skipped:
+
+```php
+if ($value === null && $this->isConditional($name)) {
+ continue; // No error for absent conditional variable
+}
+```
+
+This supports optional configuration: "If REDIS_HOST is set, it must not be empty."
+
+### ValidationRule Contract
+
+```php
+interface ValidationRule {
+ public function passes(string $value): bool;
+ public function message(): string; // "{name}" placeholder replaced at assertion time
+}
+```
+
+All 10 built-in rules implement this contract. Custom rules are added via:
+
+```php
+$validator->rule(new CustomRule(), 'VAR_NAME');
+// or
+$validator->custom('VAR_NAME', fn(string $v) => strlen($v) >= 8, '{name} must be at least 8 characters.');
+```
+
+### Built-in Rules
+
+| Rule | Validation |
+|---|---|
+| NotEmptyRule | `trim($value) !== ''` |
+| IsIntegerRule | `/\A[+-]?\d+\z/` |
+| IsBooleanRule | `true/false/1/0/yes/no/on/off` |
+| IsNumericRule | `is_numeric()` |
+| BetweenRule | `$value >= $min && $value <= $max` |
+| AllowedValuesRule | `in_array($value, $allowed, true)` |
+| MatchesRegexRule | `preg_match($pattern, $value)` |
+| UrlRule | `filter_var(FILTER_VALIDATE_URL)` |
+| EmailRule | `filter_var(FILTER_VALIDATE_EMAIL)` |
+| CustomRule | User-provided `Closure(string): bool` |
+
+## Consequences
+
+**Positive:**
+- All errors surfaced in one pass — setup/debugging cycle reduced from O(n) restarts to O(1).
+- Fluent API is readable as a specification document.
+- Conditional validation avoids false positives for optional configuration.
+- Custom rules require no subclassing — closures or `ValidationRule` implementations work equally.
+- `{name}` placeholder in messages enables reusable rule instances across variables.
+
+**Negative:**
+- Rule execution order within a variable is insertion order, not dependency order. A `between()` rule on a non-integer value will fail with a between error rather than a type error. Mitigation: chain `isInteger()` before `between()` — both failures will be reported.
+- `$currentTargets` state makes the validator stateful during construction. This is acceptable because the validator is a short-lived builder object, not a long-lived service.
diff --git a/docs/spec/SPEC-001-env-syntax.md b/docs/spec/SPEC-001-env-syntax.md
new file mode 100644
index 0000000..108c55c
--- /dev/null
+++ b/docs/spec/SPEC-001-env-syntax.md
@@ -0,0 +1,234 @@
+# SPEC-001: .env File Syntax
+
+**Version:** 1.0
+**Date:** 2024-01-15
+**Applies to:** KaririCode\Dotenv 4.0+
+
+## 1. Overview
+
+This specification defines the `.env` file syntax supported by KaririCode\Dotenv. The syntax is a superset of POSIX shell variable assignment, compatible with Docker `.env` files and Bash variable declarations.
+
+## 2. File Encoding
+
+Files must be UTF-8 encoded. BOM (Byte Order Mark) is not supported. Line endings are normalized: CRLF (`\r\n`) and CR (`\r`) are converted to LF (`\n`) before parsing.
+
+## 3. Line Types
+
+### 3.1 Empty Lines
+
+Empty lines and lines containing only whitespace are ignored.
+
+### 3.2 Comments
+
+Lines where the first non-whitespace character is `#` are comments and are ignored entirely.
+
+```ini
+# This is a comment
+ # This is also a comment (leading whitespace allowed)
+```
+
+### 3.3 Variable Assignments
+
+```
+NAME=VALUE
+```
+
+The `=` separator is required. Whitespace around `=` is stripped from the name (right side) and value (left side):
+
+```ini
+FOO = bar # name="FOO", value="bar"
+FOO =bar # name="FOO", value="bar"
+FOO= bar # name="FOO", value="bar"
+```
+
+### 3.4 Bare Names
+
+A line with a valid name but no `=` is treated as an empty string assignment:
+
+```ini
+FOO # name="FOO", value=""
+```
+
+### 3.5 Export Prefix
+
+The `export` keyword is silently stripped:
+
+```ini
+export FOO=bar # equivalent to FOO=bar
+```
+
+## 4. Variable Names
+
+### 4.1 Default Mode (strictNames: false)
+
+Pattern: `[A-Za-z_][A-Za-z0-9_.]*`
+
+Valid: `FOO`, `foo_bar`, `App.Config`, `_PRIVATE`, `DB_HOST_1`
+Invalid: `1FOO` (starts with digit), `FOO-BAR` (contains hyphen), `FOO BAR` (contains space)
+
+### 4.2 Strict Mode (strictNames: true)
+
+Pattern: `[A-Z][A-Z0-9_]*`
+
+Valid: `FOO`, `DB_HOST`, `APP_ENV_1`
+Invalid: `foo` (lowercase), `_FOO` (starts with underscore), `App.Config` (dots and lowercase)
+
+Strict mode enforces the POSIX convention for environment variable names.
+
+## 5. Values
+
+### 5.1 Unquoted Values
+
+Everything after `=` until the end of line, with:
+- Trailing inline comments stripped: `FOO=bar # comment` → `"bar"`
+- Leading and trailing whitespace trimmed
+- Variable interpolation applied (§6)
+
+```ini
+FOO=hello world # "hello world"
+FOO=hello world # note # "hello world"
+```
+
+### 5.2 Double-Quoted Values
+
+Delimited by `"..."`. Support:
+- Escape sequences (§5.4)
+- Variable interpolation (§6)
+- Multiline values (§5.5)
+
+```ini
+FOO="hello world" # "hello world"
+FOO="hello # world" # "hello # world" (# is not a comment inside quotes)
+```
+
+### 5.3 Single-Quoted Values
+
+Delimited by `'...'`. Contents are literal — no escape processing, no interpolation.
+
+```ini
+FOO='hello $WORLD' # "hello $WORLD" (literal dollar sign)
+FOO='hello\nworld' # "hello\nworld" (literal backslash-n)
+```
+
+### 5.4 Escape Sequences (Double-Quoted Only)
+
+| Sequence | Result |
+|---|---|
+| `\n` | Newline (LF) |
+| `\r` | Carriage return (CR) |
+| `\t` | Tab |
+| `\"` | Literal double quote |
+| `\\` | Literal backslash |
+| `\$` | Literal dollar sign (suppresses interpolation) |
+| `\x` (other) | Literal `\x` (unknown escapes pass through) |
+
+### 5.5 Multiline Values
+
+Double-quoted values can span multiple lines. The newlines are preserved literally:
+
+```ini
+RSA_KEY="-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA...
+-----END RSA PRIVATE KEY-----"
+```
+
+Single-quoted values cannot span multiple lines — an unterminated single quote on the same line is a parse error.
+
+### 5.6 Empty Values
+
+```ini
+FOO= # "" (empty string)
+FOO="" # "" (empty string)
+FOO='' # "" (empty string)
+```
+
+## 6. Variable Interpolation
+
+Interpolation is applied in **unquoted** and **double-quoted** values. Single-quoted values are never interpolated.
+
+### 6.1 Resolution Order
+
+Variables are resolved against:
+1. Already-parsed variables in the current file (top-to-bottom)
+2. `$_ENV`
+3. `$_SERVER`
+4. Empty string (if unresolved)
+
+### 6.2 Brace Syntax
+
+```ini
+FOO=${BAR} # Value of BAR
+FOO=${BAR:-default} # BAR if set and non-empty, else "default"
+FOO=${BAR:+alternate} # "alternate" if BAR is set and non-empty, else ""
+```
+
+### 6.3 Bare Syntax
+
+```ini
+FOO=$BAR # Value of BAR (terminated by non-alphanumeric/non-underscore)
+FOO=$BAR_BAZ # Value of BAR_BAZ (underscores are part of the name)
+```
+
+### 6.4 Operator Semantics
+
+| Operator | BAR is set and non-empty | BAR is unset or empty |
+|---|---|---|
+| `${BAR}` | Value of BAR | `""` |
+| `${BAR:-default}` | Value of BAR | `"default"` |
+| `${BAR:+alternate}` | `"alternate"` | `""` |
+
+### 6.5 Nesting Limitation
+
+Nested interpolation within operator operands is not supported:
+
+```ini
+# NOT supported:
+FOO=${BAR:-${BAZ}} # The operand is the literal string "${BAZ}"
+```
+
+This matches the behavior of simple shell implementations. For complex defaults, use multiple lines:
+
+```ini
+BAZ_DEFAULT=fallback
+FOO=${BAR:-${BAZ_DEFAULT}} # Still not supported; use:
+# BAZ=fallback
+# FOO=${BAR:-fallback}
+```
+
+## 7. Inline Comments
+
+Comments starting with `#` preceded by whitespace are stripped from **unquoted values only**:
+
+```ini
+FOO=bar # comment # "bar"
+FOO="bar # not comment" # "bar # not comment"
+FOO='bar # not comment' # "bar # not comment"
+FOO=bar#notacomment # "bar#notacomment" (no preceding whitespace)
+```
+
+## 8. Error Handling
+
+| Condition | Exception |
+|---|---|
+| Invalid variable name | `ParseException::invalidVariableName()` |
+| Unterminated double quote | `ParseException::unterminatedQuote()` |
+| Unterminated single quote | `ParseException::unterminatedQuote()` |
+
+All exceptions include the line number and file path for diagnostics.
+
+## 9. Grammar (Informal BNF)
+
+```
+file = { line LF }
+line = empty | comment | assignment
+empty = [ WS ]
+comment = [ WS ] "#" TEXT
+assignment = [ "export" WS ] NAME [ WS ] "=" [ WS ] value
+NAME = [A-Za-z_] [A-Za-z0-9_.]*
+value = double_quoted | single_quoted | unquoted
+double_quoted = '"' { DQ_CHAR | escape | interpolation } '"'
+single_quoted = "'" { SQ_CHAR } "'"
+unquoted = { UQ_CHAR | interpolation } [ WS "#" TEXT ]
+escape = "\" ( "n" | "r" | "t" | '"' | "\" | "$" | ANY )
+interpolation = "$" NAME | "${" NAME [ ( ":-" | ":+" ) OPERAND ] "}"
+```
diff --git a/docs/spec/SPEC-002-type-system.md b/docs/spec/SPEC-002-type-system.md
new file mode 100644
index 0000000..e834190
--- /dev/null
+++ b/docs/spec/SPEC-002-type-system.md
@@ -0,0 +1,185 @@
+# SPEC-002: Type System
+
+**Version:** 1.0
+**Date:** 2024-01-20
+**Applies to:** KaririCode\Dotenv 4.0+
+
+## 1. Overview
+
+The Type System performs automatic detection and casting of environment variable values from their raw string representation into typed PHP values. It operates in two phases: detection (string → ValueType) and casting (string × ValueType → mixed).
+
+## 2. Value Types
+
+```php
+enum ValueType {
+ case String; // Default fallback
+ case Integer; // Whole numbers with optional sign
+ case Float; // Decimal numbers and scientific notation
+ case Boolean; // true/false, yes/no, on/off
+ case Null; // null, NULL, (null)
+ case Json; // JSON objects: {...}
+ case Array; // JSON arrays: [...]
+}
+```
+
+## 3. Detection Rules
+
+Detectors execute in priority order (highest first). The first non-null result wins.
+
+### 3.1 Null (Priority 200)
+
+Matches exactly: `"null"`, `"NULL"`, `"(null)"`.
+
+Empty string (`""`) is **not** null — it is `ValueType::String`. This distinction is intentional: `FOO=` (empty string) and `FOO=null` (explicit null) carry different semantics.
+
+### 3.2 Boolean (Priority 190)
+
+Case-insensitive match against:
+
+| Truthy | Falsy |
+|---|---|
+| `true`, `TRUE`, `True` | `false`, `FALSE`, `False` |
+| `yes`, `YES`, `Yes` | `no`, `NO`, `No` |
+| `on`, `ON`, `On` | `off`, `OFF`, `Off` |
+| `(true)` | `(false)` |
+
+### 3.3 Integer (Priority 180)
+
+Pattern: `/\A[+-]?\d+\z/`
+
+Matches: `42`, `-10`, `+99`, `0`, `00042`
+Does not match: `""`, `"-"`, `"+"`, `"3.14"`, `"1e10"`
+
+### 3.4 Float (Priority 170)
+
+Pattern: `/\A[+-]?(\d+\.?\d*|\d*\.?\d+)([eE][+-]?\d+)?\z/`
+
+**Prerequisite:** The value must contain a decimal point (`.`) or an exponent marker (`e`/`E`). This prevents `"42"` from being detected as float.
+
+Matches: `3.14`, `-0.5`, `1e10`, `2.5E-3`, `.5`, `3.`
+Does not match: `42` (no dot or exponent — detected as integer)
+
+### 3.5 JSON Object (Priority 160)
+
+Conditions:
+1. Trimmed value starts with `{` and ends with `}`.
+2. `json_decode()` succeeds (`json_last_error() === JSON_ERROR_NONE`).
+
+Matches: `{"key": "value"}`, `{"nested": {"a": 1}}`
+Does not match: `{invalid json}`, `{` (unclosed)
+
+### 3.6 JSON Array (Priority 150)
+
+Conditions:
+1. Trimmed value starts with `[` and ends with `]`.
+2. `json_decode(..., true)` returns a PHP array.
+
+Matches: `["a", "b"]`, `[1, 2, 3]`
+Does not match: `[invalid]`, `[` (unclosed)
+
+### 3.7 String (Fallback)
+
+If no detector matches, the value is `ValueType::String`. No casting is applied.
+
+## 4. Casting Rules
+
+### 4.1 Null → `null`
+
+Always returns PHP `null`.
+
+### 4.2 Boolean → `bool`
+
+Truthy values: `true`, `yes`, `on`, `(true)` (case-insensitive) → `true`
+Everything else in the boolean detection set → `false`
+
+### 4.3 Integer → `int`
+
+Direct cast: `(int) $value`
+
+### 4.4 Float → `float`
+
+Direct cast: `(float) $value`
+
+### 4.5 JSON Object → `array`
+
+`json_decode($value, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR)`
+
+Returns an associative array. Throws `\JsonException` on malformed input (fail-fast).
+
+### 4.6 JSON Array → `array`
+
+Same as JSON Object — `json_decode()` with `associative: true`.
+
+### 4.7 String → `string`
+
+Identity function — value returned as-is.
+
+## 5. Extension API
+
+### 5.1 Custom Detector
+
+```php
+use KaririCode\Dotenv\Contract\TypeDetector;
+use KaririCode\Dotenv\Enum\ValueType;
+
+final readonly class UuidDetector implements TypeDetector
+{
+ public function priority(): int { return 195; } // Between Null and Boolean
+
+ public function detect(string $value): ?ValueType
+ {
+ return preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i', $value) === 1
+ ? ValueType::String // Detected as UUID, but cast as string
+ : null;
+ }
+}
+
+$dotenv->addTypeDetector(new UuidDetector());
+```
+
+### 5.2 Custom Caster
+
+```php
+use KaririCode\Dotenv\Contract\TypeCaster;
+
+$dotenv->addTypeCaster(ValueType::Integer, new class implements TypeCaster {
+ public function cast(string $value): int
+ {
+ return abs((int) $value); // Always positive
+ }
+});
+```
+
+Custom casters replace the default for the given `ValueType`.
+
+### 5.3 Priority Guidelines
+
+| Range | Reserved For |
+|---|---|
+| 200–250 | Null-like types (highest precedence) |
+| 150–199 | Scalar types (boolean, integer, float) |
+| 100–149 | Structured types (JSON, arrays) |
+| 50–99 | Application-specific detectors |
+| 1–49 | Low-priority catch-alls |
+
+## 6. Disable Type Casting
+
+```php
+$config = new DotenvConfiguration(typeCasting: false);
+```
+
+When disabled:
+- All values remain `ValueType::String`.
+- No detector or caster is invoked.
+- `env()` returns raw strings.
+- `EnvironmentVariable::$type` is always `ValueType::String`.
+
+## 7. The `env()` Helper
+
+The global `env()` function resolves variables from `$_ENV` → `$_SERVER` → `getenv()` and applies the default `TypeSystem` pipeline. It maintains a static `TypeSystem` instance for the lifetime of the request.
+
+```php
+function env(string $key, mixed $default = null): mixed
+```
+
+If the variable is not found in any source, `$default` is returned without type casting.
diff --git a/docs/spec/SPEC-003-encryption.md b/docs/spec/SPEC-003-encryption.md
new file mode 100644
index 0000000..667b9ef
--- /dev/null
+++ b/docs/spec/SPEC-003-encryption.md
@@ -0,0 +1,213 @@
+# SPEC-003: Encryption
+
+**Version:** 1.0
+**Date:** 2024-02-10
+**Applies to:** KaririCode\Dotenv 4.3+
+
+## 1. Overview
+
+KaririCode\Dotenv supports per-value AES-256-GCM encryption, allowing secrets to be committed to version control while remaining confidential. Decryption is transparent during `load()` — no application code changes are required.
+
+## 2. Prerequisites
+
+- PHP extension: ext-openssl (bundled in standard PHP distributions)
+- A 256-bit encryption key (generated via `KeyPair::generate()` or the `keygen` CLI command)
+
+## 3. Key Management
+
+### 3.1 Key Generation
+
+```php
+use KaririCode\Dotenv\Security\KeyPair;
+
+$keyPair = KeyPair::generate();
+echo $keyPair->privateKey; // 64-char hex string (256-bit key)
+echo $keyPair->publicId; // 8-char identifier (first 8 chars of SHA-256 hash)
+```
+
+CLI equivalent:
+
+```bash
+vendor/bin/kariricode-dotenv keygen
+```
+
+### 3.2 Key Format
+
+| Representation | Length | Example |
+|---|---|---|
+| Hex string | 64 characters | `a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2` |
+| Raw binary | 32 bytes | *(binary)* |
+
+The `Encryptor` constructor accepts both formats. Hex strings are auto-detected (64 chars, all hex digits) and decoded to binary internally.
+
+### 3.3 Key Storage
+
+The private key must **never** be committed to version control. Recommended storage:
+
+1. **Environment variable:** `DOTENV_PRIVATE_KEY` (set by the orchestrator)
+2. **CI/CD secret:** Injected during deployment
+3. **Secret manager:** Retrieved at application bootstrap
+
+### 3.4 Key Reconstitution
+
+```php
+$keyPair = KeyPair::fromPrivateKey($hexKey);
+// Derives the same publicId from the key — useful for verification
+```
+
+### 3.5 Public ID
+
+The 8-character public ID is a non-secret identifier derived from `sha256(raw_key)[0:8]`. It allows referencing keys in multi-environment setups without exposing the key material:
+
+```
+production: key_id=f7e8d9c0
+staging: key_id=a1b2c3d4
+```
+
+## 4. Wire Format
+
+### 4.1 Encrypted Value
+
+```
+encrypted:
+```
+
+### 4.2 Binary Layout
+
+```
+Offset Length Field
+0 12 Nonce (random IV)
+12 N Ciphertext (AES-256-GCM output, N ≥ 0)
+12+N 16 Authentication Tag (GCM MAC)
+```
+
+Total binary size: `28 + plaintext_length` bytes (before base64 encoding).
+
+### 4.3 Detection
+
+A value is considered encrypted if and only if it starts with the ASCII prefix `encrypted:`:
+
+```php
+Encryptor::isEncrypted($value); // str_starts_with($value, 'encrypted:')
+```
+
+## 5. Encryption Process
+
+```
+Input: plaintext string
+Output: "encrypted:" + base64(nonce + ciphertext + tag)
+
+1. Generate 12 random bytes (nonce) via random_bytes(12)
+2. Encrypt using openssl_encrypt():
+ - Algorithm: aes-256-gcm
+ - Key: 32-byte binary key
+ - IV: nonce (12 bytes)
+ - Options: OPENSSL_RAW_DATA
+ - Tag output: 16 bytes
+3. Concatenate: nonce + ciphertext + tag
+4. Base64-encode the concatenation
+5. Prepend "encrypted:" prefix
+```
+
+## 6. Decryption Process
+
+```
+Input: "encrypted:" + base64_payload
+Output: plaintext string
+
+1. Verify prefix "encrypted:" — if absent, return value unchanged (passthrough)
+2. Strip prefix, base64_decode the remainder
+3. Validate minimum length: ≥ 28 bytes (12 nonce + 0 ciphertext + 16 tag)
+4. Extract:
+ - nonce: bytes[0..11]
+ - tag: bytes[-16..]
+ - ciphertext: bytes[12..-16]
+5. Decrypt using openssl_decrypt():
+ - Algorithm: aes-256-gcm
+ - Key: 32-byte binary key
+ - IV: nonce
+ - Options: OPENSSL_RAW_DATA
+ - Tag: 16-byte tag
+6. If openssl_decrypt returns false → throw RuntimeException
+7. Return plaintext
+```
+
+## 7. Integration with Load Pipeline
+
+Decryption occurs inside `Dotenv::setVariable()`, after allow/deny filtering and before type detection:
+
+```
+Raw value from parser
+ → Allow/deny check
+ → Encryption detection (str_starts_with 'encrypted:')
+ → Yes: decrypt → decrypted string
+ → No: passthrough
+ → Type detection → Type casting → Processor application → Store
+```
+
+The decrypted value is stored in `EnvironmentVariable::$rawValue` and populated into `$_ENV`/`$_SERVER`. The original encrypted string is never stored in application-accessible state.
+
+## 8. Configuration
+
+### 8.1 Via DotenvConfiguration
+
+```php
+$config = new DotenvConfiguration(
+ encryptionKey: 'a1b2c3d4...', // 64-char hex
+);
+```
+
+### 8.2 Via Environment Variable
+
+If `DotenvConfiguration::$encryptionKey` is null, the Dotenv constructor checks:
+1. `$_SERVER['DOTENV_PRIVATE_KEY']`
+2. `$_ENV['DOTENV_PRIVATE_KEY']`
+
+This allows setting the key once in the container orchestrator.
+
+### 8.3 No Key Configured
+
+If no encryption key is available, encrypted values remain as-is (the literal `encrypted:...` string). No error is thrown — this allows committed `.env` files with encrypted values to be loaded in environments where decryption is not needed (e.g., build systems that only need non-secret variables).
+
+## 9. Security Properties
+
+| Property | Guarantee |
+|---|---|
+| Confidentiality | AES-256 encryption (256-bit key space) |
+| Integrity | GCM authentication tag (128-bit MAC) |
+| Nonce uniqueness | 96-bit random nonce per encryption (2⁹⁶ space) |
+| No padding oracle | GCM is a streaming mode — no padding |
+| Key validation | Rejects keys ≠ 32 bytes at construction time |
+| Timing safety | `openssl_decrypt()` uses constant-time tag comparison internally |
+
+## 10. Error Conditions
+
+| Condition | Exception | Message |
+|---|---|---|
+| Key not 32 bytes | `InvalidArgumentException` | "Encryption key must be 32 bytes (256-bit) or 64-char hex string." |
+| `openssl_encrypt()` fails | `RuntimeException` | "Encryption failed: {openssl_error_string}" |
+| Invalid base64 payload | `RuntimeException` | "Invalid encrypted payload: malformed base64 or too short." |
+| Decryption failure (wrong key, corruption) | `RuntimeException` | "Decryption failed — wrong key or corrupted payload." |
+
+## 11. CLI Commands
+
+### 11.1 `keygen`
+
+Generates a new key pair and prints both values.
+
+### 11.2 `encrypt`
+
+Encrypts all plaintext values in a `.env` file, writing the result to the same file or a specified output path.
+
+```bash
+vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4...
+vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4... --output=.env.encrypted
+```
+
+### 11.3 `decrypt`
+
+Decrypts all encrypted values, producing a plaintext `.env` file.
+
+```bash
+vendor/bin/kariricode-dotenv decrypt .env --key=a1b2c3d4...
+```
diff --git a/docs/spec/SPEC-004-validation.md b/docs/spec/SPEC-004-validation.md
new file mode 100644
index 0000000..f102175
--- /dev/null
+++ b/docs/spec/SPEC-004-validation.md
@@ -0,0 +1,234 @@
+# SPEC-004: Validation
+
+**Version:** 1.0
+**Date:** 2024-02-05
+**Applies to:** KaririCode\Dotenv 4.1+
+
+## 1. Overview
+
+KaririCode\Dotenv provides two validation mechanisms:
+
+1. **Fluent DSL** — Programmatic rule definition via `EnvironmentValidator`.
+2. **Schema file** — Declarative rule definition via `.env.schema` (see SPEC-005).
+
+Both mechanisms collect all failures before throwing a single `ValidationException`.
+
+## 2. Entry Points
+
+### 2.1 Simple Required Check
+
+```php
+$dotenv->required('DB_HOST', 'DB_PORT', 'APP_ENV');
+```
+
+Throws `ValidationException` listing all missing variables.
+
+### 2.2 Fluent Validator
+
+```php
+$dotenv->validate() // Returns EnvironmentValidator
+ ->required(...)
+ ->isInteger(...)
+ ->assert(); // Executes all rules, throws on failure
+```
+
+### 2.3 Schema Validation
+
+```php
+$dotenv->loadWithSchema(__DIR__ . '/.env.schema');
+```
+
+Parses the schema, loads `.env` if not already loaded, applies rules, and asserts.
+
+## 3. EnvironmentValidator API
+
+### 3.1 Targeting Methods
+
+These methods set the current target variables for subsequent rule methods.
+
+| Method | Behavior |
+|---|---|
+| `required(string ...$names): self` | Marks variables as required (must exist). Sets targets. |
+| `ifPresent(string ...$names): self` | Sets targets. Rules only apply if variable exists. |
+
+### 3.2 Rule Methods
+
+Rule methods apply their rule to the **current targets** (set by the most recent targeting method or rule method that accepts names).
+
+| Method | Rule Applied | Target Behavior |
+|---|---|---|
+| `notEmpty(string ...$names)` | `NotEmptyRule` | If `$names` given, sets new targets; otherwise uses current. |
+| `isInteger(string ...$names)` | `IsIntegerRule` | Same. |
+| `isBoolean(string ...$names)` | `IsBooleanRule` | Same. |
+| `isNumeric(string ...$names)` | `IsNumericRule` | Same. |
+| `between(int\|float $min, int\|float $max)` | `BetweenRule` | Always uses current targets (chainable). |
+| `allowedValues(string $name, array $allowed)` | `AllowedValuesRule` | Sets target to `[$name]`. |
+| `matchesRegex(string $name, string $pattern)` | `MatchesRegexRule` | Sets target to `[$name]`. |
+| `url(string ...$names)` | `UrlRule` | If `$names` given, sets new targets. |
+| `email(string ...$names)` | `EmailRule` | Same. |
+| `custom(string $name, Closure $callback, string $message)` | `CustomRule` | Sets target to `[$name]`. |
+| `rule(ValidationRule $rule, string ...$names)` | Arbitrary rule | If `$names` given, sets new targets. |
+
+### 3.3 Chaining
+
+Methods that inherit current targets enable natural chains:
+
+```php
+->isInteger('DB_PORT')->between(1, 65535)
+// isInteger sets target to ['DB_PORT']
+// between inherits target ['DB_PORT']
+```
+
+### 3.4 Execution
+
+```php
+public function assert(): void
+```
+
+1. Checks all `required` variables for presence.
+2. Iterates all variables with registered rules.
+3. Skips absent variables in conditional mode (`ifPresent`).
+4. Skips absent required variables from rule execution (already reported as missing).
+5. Collects all failure messages.
+6. Throws `ValidationException::batchErrors($errors)` if any failures exist.
+
+## 4. Built-in Rules
+
+### 4.1 NotEmptyRule
+
+```php
+trim($value) !== ''
+```
+
+Message: `"{name} must not be empty."`
+
+### 4.2 IsIntegerRule
+
+```php
+preg_match('/\A[+-]?\d+\z/', $value) === 1
+```
+
+Message: `"{name} must be an integer."`
+
+### 4.3 IsBooleanRule
+
+Accepted values (case-insensitive): `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off`.
+
+Message: `"{name} must be a boolean (true/false, yes/no, on/off, 1/0)."`
+
+### 4.4 IsNumericRule
+
+```php
+is_numeric($value)
+```
+
+Message: `"{name} must be numeric."`
+
+### 4.5 BetweenRule
+
+```php
+is_numeric($value) && ($value + 0) >= $min && ($value + 0) <= $max
+```
+
+Message: `"{name} must be between {min} and {max}."`
+
+### 4.6 AllowedValuesRule
+
+```php
+in_array($value, $allowed, strict: true)
+```
+
+Message: `"{name} must be one of: {comma-separated list}."`
+
+### 4.7 MatchesRegexRule
+
+```php
+preg_match($pattern, $value) === 1
+```
+
+Message: `"{name} must match pattern {pattern}."`
+
+### 4.8 UrlRule
+
+```php
+filter_var($value, FILTER_VALIDATE_URL) !== false
+```
+
+Message: `"{name} must be a valid URL."`
+
+### 4.9 EmailRule
+
+```php
+filter_var($value, FILTER_VALIDATE_EMAIL) !== false
+```
+
+Message: `"{name} must be a valid email address."`
+
+### 4.10 CustomRule
+
+User-provided `Closure(string): bool` with custom message.
+
+Message: User-defined (default: `"{name} failed custom validation."`)
+
+## 5. Custom ValidationRule Contract
+
+```php
+interface ValidationRule
+{
+ public function passes(string $value): bool;
+ public function message(): string;
+}
+```
+
+The `{name}` placeholder in `message()` is replaced with the variable name at assertion time.
+
+```php
+final readonly class MinLengthRule implements ValidationRule
+{
+ public function __construct(private int $minLength) {}
+
+ public function passes(string $value): bool
+ {
+ return strlen($value) >= $this->minLength;
+ }
+
+ public function message(): string
+ {
+ return "{name} must be at least {$this->minLength} characters.";
+ }
+}
+
+$dotenv->validate()
+ ->rule(new MinLengthRule(32), 'API_KEY')
+ ->assert();
+```
+
+## 6. ValidationException
+
+```php
+final class ValidationException extends DotenvException
+{
+ public static function missingRequired(array $missing): self;
+ public static function batchErrors(array $errors): self;
+ public static function schemaViolation(string $message): self;
+
+ /** @return list All failure messages. */
+ public function errors(): array;
+}
+```
+
+The `errors()` method returns a flat list of failure messages, suitable for display in logs or CLI output.
+
+## 7. Value Resolution
+
+The `EnvironmentValidator` receives a `Closure(string): ?string` that resolves variable names to raw string values. This decouples validation from the storage mechanism:
+
+```php
+new EnvironmentValidator(
+ fn (string $name): ?string => $this->resolveRawValue($name),
+);
+```
+
+Resolution order inside `resolveRawValue()`: `$this->variables[$name]->rawValue` → `$_ENV[$name]` → `$_SERVER[$name]` → `null`.
+
+Validation operates on **raw string values**, not typed values. This ensures that `isInteger('DB_PORT')` validates the string `"5432"`, not the integer `5432`.
diff --git a/docs/spec/SPEC-005-schema-format.md b/docs/spec/SPEC-005-schema-format.md
new file mode 100644
index 0000000..a1ab8de
--- /dev/null
+++ b/docs/spec/SPEC-005-schema-format.md
@@ -0,0 +1,243 @@
+# SPEC-005: Schema Format (.env.schema)
+
+**Version:** 1.0
+**Date:** 2024-03-01
+**Applies to:** KaririCode\Dotenv 4.4+
+
+## 1. Overview
+
+The `.env.schema` file provides declarative validation rules for environment variables using an INI-like syntax. It eliminates the need for programmatic validation code and serves as living documentation of the application's environment contract.
+
+## 2. File Format
+
+### 2.1 Encoding
+
+UTF-8. Line endings: LF or CRLF (normalized to LF internally).
+
+### 2.2 Structure
+
+```ini
+# Comments start with # or ;
+; INI-style comments also supported
+
+[VARIABLE_NAME]
+directive = value
+directive = value
+
+[ANOTHER_VARIABLE]
+directive = value
+```
+
+### 2.3 Section Headers
+
+```
+[NAME]
+```
+
+Pattern: `[A-Za-z_][A-Za-z0-9_]*` enclosed in square brackets. Each section defines rules for one environment variable.
+
+### 2.4 Directives
+
+```
+key = value
+```
+
+The `=` separator is required. Leading and trailing whitespace around both key and value is trimmed.
+
+### 2.5 Comments and Empty Lines
+
+Lines starting with `#` or `;` (after optional whitespace) are ignored. Empty lines are ignored.
+
+## 3. Supported Directives
+
+### 3.1 `required`
+
+**Values:** `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` (case-insensitive)
+**Default:** `false`
+
+When `true`, the variable must exist (non-null). Missing required variables are reported as errors.
+
+```ini
+[DB_HOST]
+required = true
+```
+
+### 3.2 `type`
+
+**Values:** `string`, `integer`, `boolean`, `numeric`, `email`, `url`
+
+Applies the corresponding type validation rule.
+
+| Type | Validator Rule | Description |
+|---|---|---|
+| `string` | *(none)* | No additional validation — all values are strings. |
+| `integer` | `IsIntegerRule` | Must match `/\A[+-]?\d+\z/`. |
+| `boolean` | `IsBooleanRule` | Must be true/false/1/0/yes/no/on/off. |
+| `numeric` | `IsNumericRule` | Must pass `is_numeric()`. |
+| `email` | `EmailRule` | Must pass `FILTER_VALIDATE_EMAIL`. |
+| `url` | `UrlRule` | Must pass `FILTER_VALIDATE_URL`. |
+
+Unknown types throw `ValidationException::schemaViolation()` at schema application time.
+
+```ini
+[DB_PORT]
+type = integer
+```
+
+### 3.3 `notEmpty`
+
+**Values:** Boolean (same as `required`)
+
+When `true`, the variable's trimmed value must not be an empty string.
+
+```ini
+[DB_HOST]
+required = true
+notEmpty = true
+```
+
+### 3.4 `min` / `max`
+
+**Values:** Numeric (integer or float)
+
+Defines a numeric range. Both `min` and `max` must be present for the range check to apply. The underlying rule is `BetweenRule`, which requires the value to be numeric.
+
+```ini
+[DB_PORT]
+type = integer
+min = 1
+max = 65535
+```
+
+If only `min` or only `max` is specified, the range check is not applied. This prevents ambiguous open-ended ranges.
+
+### 3.5 `allowed`
+
+**Values:** Comma-separated list of acceptable values. Whitespace around each value is trimmed.
+
+```ini
+[APP_ENV]
+allowed = local, staging, production
+```
+
+Produces: `AllowedValuesRule(['local', 'staging', 'production'])`.
+
+### 3.6 `regex`
+
+**Values:** A PCRE pattern including delimiters.
+
+```ini
+[API_KEY]
+regex = /^[a-f0-9]{32}$/
+```
+
+Produces: `MatchesRegexRule('/^[a-f0-9]{32}$/')`.
+
+### 3.7 `default`
+
+**Values:** Any string.
+
+Informational only — documents the default value but does **not** inject it into the environment. Default injection is out of scope for the schema parser; the `.env` file should contain the actual default.
+
+```ini
+[DB_PORT]
+default = 5432
+```
+
+## 4. Processing Order
+
+For each variable section, directives are applied in this order:
+
+1. **required / optional** — Register as required or conditional (`ifPresent`).
+2. **notEmpty** — Add `NotEmptyRule` if `true`.
+3. **type** — Add the corresponding type rule.
+4. **min + max** — Add `BetweenRule` if both are present.
+5. **allowed** — Add `AllowedValuesRule`.
+6. **regex** — Add `MatchesRegexRule`.
+
+This order ensures that presence is checked before content, and type is validated before range.
+
+## 5. Interaction with Fluent DSL
+
+Schema validation and fluent DSL validation are not mutually exclusive. Both can be used in the same application:
+
+```php
+$dotenv->loadWithSchema(__DIR__ . '/.env.schema'); // Declarative rules
+$dotenv->validate()
+ ->custom('DB_DSN', fn($v) => str_starts_with($v, 'pgsql:'))
+ ->assert(); // Additional programmatic rules
+```
+
+## 6. Full Example
+
+```ini
+# .env.schema — Application Environment Contract
+# Generated: 2024-03-01
+
+[APP_ENV]
+required = true
+allowed = local, staging, production
+
+[APP_DEBUG]
+required = true
+type = boolean
+
+[APP_URL]
+required = true
+type = url
+notEmpty = true
+
+[DB_HOST]
+required = true
+notEmpty = true
+
+[DB_PORT]
+required = true
+type = integer
+min = 1
+max = 65535
+default = 5432
+
+[DB_NAME]
+required = true
+notEmpty = true
+
+[DB_USER]
+required = true
+notEmpty = true
+
+[DB_PASSWORD]
+required = true
+
+[REDIS_HOST]
+required = false
+type = string
+
+[REDIS_PORT]
+required = false
+type = integer
+min = 1
+max = 65535
+
+[ADMIN_EMAIL]
+type = email
+
+[API_KEY]
+regex = /^[a-f0-9]{32}$/
+
+[LOG_LEVEL]
+allowed = debug, info, warning, error, critical
+default = info
+```
+
+## 7. Error Reporting
+
+Schema validation failures are reported through the same `ValidationException::batchErrors()` mechanism as fluent validation. All failures across all variables are collected before throwing.
+
+```
+Environment validation failed:
+- DB_HOST is required but not defined.
+- DB_PORT must be an integer.
+- APP_ENV must be one of: local, staging, production.
+- API_KEY must match pattern /^[a-f0-9]{32}$/.
+```
diff --git a/docs/spec/SPEC-006-processors.md b/docs/spec/SPEC-006-processors.md
new file mode 100644
index 0000000..908e8fe
--- /dev/null
+++ b/docs/spec/SPEC-006-processors.md
@@ -0,0 +1,212 @@
+# SPEC-006: Variable Processors
+
+**Version:** 1.0
+**Date:** 2024-03-01
+**Applies to:** KaririCode\Dotenv 4.4+
+
+## 1. Overview
+
+Variable processors are post-load transformers that modify environment variable values after parsing and type casting. They enable domain-specific transformations (splitting CSV into arrays, normalizing URLs, decoding base64) without polluting the parser or type system.
+
+## 2. Contract
+
+```php
+interface VariableProcessor
+{
+ public function process(string $rawValue, mixed $typedValue): mixed;
+}
+```
+
+| Parameter | Description |
+|---|---|
+| `$rawValue` | The raw string from the `.env` file (after decryption, before type casting). |
+| `$typedValue` | The value after type detection and casting. |
+| **Returns** | The transformed value that replaces `$typedValue` in the `EnvironmentVariable`. |
+
+Processors receive both the raw and typed values, allowing them to choose which representation to work with. Most processors operate on `$rawValue` (string manipulation), but pipeline processors may chain off `$typedValue`.
+
+## 3. Registration
+
+```php
+$dotenv->addProcessor(string $pattern, VariableProcessor $processor): void
+```
+
+| Parameter | Description |
+|---|---|
+| `$pattern` | Exact variable name or glob pattern. |
+| `$processor` | Instance implementing `VariableProcessor`. |
+
+Multiple processors can be registered for the same pattern. They execute in registration order.
+
+### 3.1 Glob Patterns
+
+| Pattern | Matches | Does Not Match |
+|---|---|---|
+| `ALLOWED_IPS` | `ALLOWED_IPS` | `ALLOWED_IPS_V6` |
+| `*_URL` | `APP_URL`, `API_URL`, `WEBHOOK_URL` | `URL_PREFIX` |
+| `DB_*` | `DB_HOST`, `DB_PORT`, `DB_NAME` | `REDIS_HOST` |
+| `*_SECRET*` | `DB_SECRET`, `API_SECRET_KEY` | `SECRET` (no prefix) |
+
+Glob matching uses `*` (any characters) and `?` (single character). The implementation converts globs to PCRE:
+
+```php
+$regex = '/\A' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote($pattern, '/')) . '\z/';
+```
+
+## 4. Execution Order
+
+Processors execute within `Dotenv::setVariable()`, after type casting and before storage:
+
+```
+Raw value → Decryption → Type detection → Type casting → Processor(s) → EnvironmentVariable
+```
+
+For a given variable, processors are applied in pattern registration order:
+
+```php
+$dotenv->addProcessor('*_URL', new TrimProcessor()); // Runs first
+$dotenv->addProcessor('*_URL', new UrlNormalizerProcessor()); // Runs second
+```
+
+If multiple patterns match the same variable, all matching processors run in registration order across all patterns.
+
+## 5. Built-in Processors
+
+### 5.1 CsvToArrayProcessor
+
+Splits a comma-delimited string into an array of trimmed values.
+
+```php
+final readonly class CsvToArrayProcessor implements VariableProcessor
+{
+ public function __construct(private string $separator = ',') {}
+
+ public function process(string $rawValue, mixed $typedValue): array
+ {
+ if (trim($rawValue) === '') {
+ return [];
+ }
+ return array_map(trim(...), explode($this->separator, $rawValue));
+ }
+}
+```
+
+| Input | Output |
+|---|---|
+| `"192.168.1.1, 10.0.0.1, 172.16.0.1"` | `['192.168.1.1', '10.0.0.1', '172.16.0.1']` |
+| `""` | `[]` |
+| `" "` | `[]` |
+
+Custom separator:
+
+```php
+new CsvToArrayProcessor(separator: '|') // "a|b|c" → ["a", "b", "c"]
+```
+
+### 5.2 Base64DecodeProcessor
+
+Decodes a base64-encoded raw value. Throws on invalid input.
+
+```php
+final readonly class Base64DecodeProcessor implements VariableProcessor
+{
+ public function process(string $rawValue, mixed $typedValue): string
+ {
+ $decoded = base64_decode($rawValue, strict: true);
+ if ($decoded === false) {
+ throw new \RuntimeException("Invalid base64 value: cannot decode.");
+ }
+ return $decoded;
+ }
+}
+```
+
+Use case: certificates, binary keys, or opaque tokens stored as base64 in `.env`.
+
+### 5.3 TrimProcessor
+
+Trims whitespace (or custom characters) from the raw value.
+
+```php
+final readonly class TrimProcessor implements VariableProcessor
+{
+ public function __construct(private string $characters = " \t\n\r\0\x0B") {}
+
+ public function process(string $rawValue, mixed $typedValue): string
+ {
+ return trim($rawValue, $this->characters);
+ }
+}
+```
+
+### 5.4 UrlNormalizerProcessor
+
+Ensures URLs end with a trailing slash. Removes duplicate trailing slashes.
+
+```php
+final readonly class UrlNormalizerProcessor implements VariableProcessor
+{
+ public function process(string $rawValue, mixed $typedValue): string
+ {
+ return rtrim($rawValue, '/') . '/';
+ }
+}
+```
+
+| Input | Output |
+|---|---|
+| `"https://api.example.com"` | `"https://api.example.com/"` |
+| `"https://api.example.com/"` | `"https://api.example.com/"` |
+| `"https://api.example.com//"` | `"https://api.example.com/"` |
+
+## 6. Custom Processors
+
+Implement `VariableProcessor` for application-specific transformations:
+
+```php
+final readonly class JsonDecodeProcessor implements VariableProcessor
+{
+ public function process(string $rawValue, mixed $typedValue): array
+ {
+ return json_decode($rawValue, true, 512, JSON_THROW_ON_ERROR);
+ }
+}
+
+final readonly class UpperCaseProcessor implements VariableProcessor
+{
+ public function process(string $rawValue, mixed $typedValue): string
+ {
+ return strtoupper($rawValue);
+ }
+}
+
+final readonly class PrefixProcessor implements VariableProcessor
+{
+ public function __construct(private string $prefix) {}
+
+ public function process(string $rawValue, mixed $typedValue): string
+ {
+ return $this->prefix . $rawValue;
+ }
+}
+```
+
+## 7. Interaction with Type Casting
+
+Processors run **after** type casting. The processor's return value replaces the typed value in the `EnvironmentVariable`:
+
+```php
+// .env: ALLOWED_IPS=192.168.1.1, 10.0.0.1
+// Without processor: type=String, value="192.168.1.1, 10.0.0.1"
+// With CsvToArrayProcessor: type=String, value=["192.168.1.1", "10.0.0.1"]
+```
+
+Note that `EnvironmentVariable::$type` reflects the **original** detection (String), not the processor's output type. The `$value` field holds the processor's output.
+
+## 8. Interaction with Caching
+
+Processors are **not** invoked when loading from cache. The cache stores raw string values, and type casting + processors run on every load (cache or file). This ensures that processor registration at runtime is always respected.
+
+## 9. Error Handling
+
+Processor exceptions propagate uncaught from `Dotenv::load()`. This is intentional — a processor failure (e.g., invalid base64) indicates a configuration error that should halt application startup.
diff --git a/docs/spec/SPEC-007-cli.md b/docs/spec/SPEC-007-cli.md
new file mode 100644
index 0000000..693e8c9
--- /dev/null
+++ b/docs/spec/SPEC-007-cli.md
@@ -0,0 +1,253 @@
+# SPEC-007: CLI Tooling
+
+**Version:** 1.0
+**Date:** 2024-03-01
+**Applies to:** KaririCode\Dotenv 4.4+
+
+## 1. Overview
+
+KaririCode\Dotenv ships a CLI tool at `vendor/bin/kariricode-dotenv` (registered in `composer.json` `bin` field). It provides 9 commands for managing `.env` files without writing PHP code.
+
+## 2. Installation
+
+The CLI is automatically available after `composer install`/`require`. No additional setup needed.
+
+```bash
+vendor/bin/kariricode-dotenv help
+```
+
+## 3. Global Options
+
+| Option | Default | Description |
+|---|---|---|
+| `--dir=` | Current working directory | Project root directory containing `.env` files. |
+
+## 4. Commands
+
+### 4.1 `debug`
+
+Lists all loaded variables with their detected types, source files, and override status.
+
+```bash
+vendor/bin/kariricode-dotenv debug [--dir=.]
+```
+
+**Output format:**
+
+```
+Variable Source Type Overridden Value
+──────────────────────────────────────────────────────────
+DB_HOST .env.local String yes localhost
+DB_PORT .env Integer no 5432
+APP_DEBUG .env Boolean no true
+APP_ENV .env.staging String yes staging
+```
+
+**Process:** Instantiates `Dotenv`, calls `bootEnv()`, then formats `debug()` output as a table.
+
+### 4.2 `validate`
+
+Validates the current `.env` configuration against a `.env.schema` file.
+
+```bash
+vendor/bin/kariricode-dotenv validate [--dir=.] [--schema=.env.schema]
+```
+
+| Option | Default | Description |
+|---|---|---|
+| `--schema=` | `.env.schema` (relative to `--dir`) | Path to the schema file. |
+
+**Exit codes:**
+- `0` — All validations passed.
+- `1` — One or more validation failures. Errors printed to STDERR.
+
+**Output on failure:**
+
+```
+✗ Environment validation failed:
+ - DB_HOST is required but not defined.
+ - DB_PORT must be an integer.
+ - APP_ENV must be one of: local, staging, production.
+```
+
+### 4.3 `encrypt`
+
+Encrypts all plaintext values in a `.env` file using AES-256-GCM.
+
+```bash
+vendor/bin/kariricode-dotenv encrypt --key= [--output=]
+```
+
+| Option | Required | Description |
+|---|---|---|
+| `` | Yes | Path to the `.env` file to encrypt. |
+| `--key=` | Yes | 64-character hex encryption key. |
+| `--output=` | No | Output path. If omitted, overwrites the input file. |
+
+**Behavior:**
+1. Parses the input file line by line.
+2. For each `KEY=VALUE` line where the value does not already start with `encrypted:`, encrypts the value.
+3. Preserves comments, empty lines, and formatting.
+4. Already-encrypted values are left unchanged (idempotent).
+
+**Output:**
+
+```
+Encrypted 5 values in .env
+ DB_PASSWORD: encrypted
+ API_SECRET: encrypted
+ SMTP_PASSWORD: encrypted
+ JWT_KEY: encrypted
+ REDIS_PASSWORD: encrypted
+Skipped 8 plaintext values (not encrypted)
+Skipped 2 already-encrypted values
+```
+
+### 4.4 `decrypt`
+
+Decrypts all encrypted values in a `.env` file, producing a plaintext version.
+
+```bash
+vendor/bin/kariricode-dotenv decrypt --key= [--output=]
+```
+
+Same options and behavior as `encrypt`, but in reverse. Plaintext values pass through unchanged.
+
+### 4.5 `cache:dump`
+
+Generates an OPcache-friendly PHP cache file from the current `.env` configuration.
+
+```bash
+vendor/bin/kariricode-dotenv cache:dump [--dir=.] [--output=.env.cache.php]
+```
+
+| Option | Default | Description |
+|---|---|---|
+| `--output=` | `.env.cache.php` (relative to `--dir`) | Cache file path. |
+
+**Process:** Loads `.env` via `bootEnv()`, then calls `dumpCache()`.
+
+### 4.6 `cache:clear`
+
+Removes the PHP cache file.
+
+```bash
+vendor/bin/kariricode-dotenv cache:clear [--dir=.] [--file=.env.cache.php]
+```
+
+### 4.7 `diff`
+
+Compares two `.env` files, showing added, removed, and changed variables.
+
+```bash
+vendor/bin/kariricode-dotenv diff
+```
+
+**Output:**
+
+```
+Comparing .env vs .env.production:
+
+ + REDIS_HOST=redis.internal (added in .env.production)
+ - DEV_TOOLBAR=true (removed in .env.production)
+ ~ DB_HOST: localhost → prod-db.internal
+ ~ APP_DEBUG: true → false
+
+Summary: 2 changed, 1 added, 1 removed, 8 unchanged
+```
+
+### 4.8 `example:generate`
+
+Generates a `.env.example` file from an existing `.env` by stripping all values.
+
+```bash
+vendor/bin/kariricode-dotenv example:generate [--dir=.] [--output=.env.example]
+```
+
+**Output file:**
+
+```ini
+# Auto-generated from .env — do not edit manually.
+# Copy to .env and fill in the values.
+
+DB_HOST=
+DB_PORT=
+DB_NAME=
+DB_USER=
+DB_PASSWORD=
+APP_ENV=
+APP_DEBUG=
+APP_URL=
+```
+
+Comments from the source file are preserved. Values are stripped to empty strings.
+
+### 4.9 `keygen`
+
+Generates a new encryption key pair.
+
+```bash
+vendor/bin/kariricode-dotenv keygen
+```
+
+**Output:**
+
+```
+KaririCode\Dotenv — Key Generation
+
+ Private key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2
+ Public ID: f7e8d9c0
+
+ Store the private key in DOTENV_PRIVATE_KEY environment variable.
+ Never commit the private key to version control.
+
+ Usage:
+ export DOTENV_PRIVATE_KEY=a1b2c3d4...
+ vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4...
+```
+
+## 5. Exit Codes
+
+| Code | Meaning |
+|---|---|
+| `0` | Success |
+| `1` | Validation failure or expected error (missing file, wrong key) |
+| `2` | Usage error (unknown command, missing required option) |
+
+## 6. Error Output
+
+All errors are written to STDERR. Normal output goes to STDOUT. This allows piping:
+
+```bash
+vendor/bin/kariricode-dotenv debug 2>/dev/null | grep DB_
+```
+
+## 7. Autoload Resolution
+
+The CLI resolves `vendor/autoload.php` by checking two paths:
+1. `__DIR__ . '/../vendor/autoload.php'` — when installed as a root dependency.
+2. `__DIR__ . '/../../../autoload.php'` — when installed as a dependency of another package.
+
+If neither path exists, the CLI prints an error to STDERR and exits with code 1.
+
+## 8. Option Parsing
+
+Options are parsed from `$argv` with a simple `--key=value` convention. No external option parser is used (zero dependencies). Boolean flags use `--flag` (no value).
+
+```php
+function parseOptions(array $args): array
+{
+ $options = [];
+ foreach ($args as $arg) {
+ if (str_starts_with($arg, '--')) {
+ $eqPos = strpos($arg, '=');
+ if ($eqPos !== false) {
+ $options[substr($arg, 2, $eqPos - 2)] = substr($arg, $eqPos + 1);
+ } else {
+ $options[substr($arg, 2)] = true;
+ }
+ }
+ }
+ return $options;
+}
+```
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index 07143a4..0000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- src/
- tests/
-
-
- vendor/*
- config/*
- tests/bootstrap.php
- tests/object-manager.php
-
-
diff --git a/phpinsights.php b/phpinsights.php
deleted file mode 100644
index 5df088e..0000000
--- a/phpinsights.php
+++ /dev/null
@@ -1,60 +0,0 @@
- 'symfony',
- 'exclude' => [
- 'src/Migrations',
- 'src/Kernel.php',
- ],
- 'add' => [],
- 'remove' => [
- \PHP_CodeSniffer\Standards\Generic\Sniffs\Formatting\SpaceAfterNotSniff::class,
- \NunoMaduro\PhpInsights\Domain\Sniffs\ForbiddenSetterSniff::class,
- \SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff::class,
- \SlevomatCodingStandard\Sniffs\Commenting\DocCommentSpacingSniff::class,
- \SlevomatCodingStandard\Sniffs\Classes\SuperfluousInterfaceNamingSniff::class,
- \SlevomatCodingStandard\Sniffs\Classes\SuperfluousExceptionNamingSniff::class,
- \SlevomatCodingStandard\Sniffs\ControlStructures\DisallowYodaComparisonSniff::class,
- \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits::class,
- \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses::class,
- \SlevomatCodingStandard\Sniffs\Classes\SuperfluousTraitNamingSniff::class,
- \SlevomatCodingStandard\Sniffs\Classes\ForbiddenPublicPropertySniff::class,
- \NunoMaduro\PhpInsights\Domain\Insights\CyclomaticComplexityIsHigh::class,
- \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions::class,
- \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses::class,
- \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenGlobals::class,
- \PHP_CodeSniffer\Standards\Squiz\Sniffs\Commenting\FunctionCommentSniff::class,
- \SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff::class,
- \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class,
- \SlevomatCodingStandard\Sniffs\Classes\ModernClassNameReferenceSniff::class,
- \PHP_CodeSniffer\Standards\Generic\Sniffs\CodeAnalysis\UselessOverridingMethodSniff::class,
- \SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff::class,
- \SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff::class,
- \SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff::class,
- \SlevomatCodingStandard\Sniffs\Arrays\TrailingArrayCommaSniff::class
- ],
- 'config' => [
- \PHP_CodeSniffer\Standards\Generic\Sniffs\Files\LineLengthSniff::class => [
- 'lineLimit' => 120,
- 'absoluteLineLimit' => 160,
- ],
- \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class => [
- 'exclude' => [
- 'src/Exception/BaseException.php',
- ],
- ],
- \SlevomatCodingStandard\Sniffs\ControlStructures\AssignmentInConditionSniff::class => [
- 'enabled' => false,
- ],
- ],
- 'requirements' => [
- 'min-quality' => 80,
- 'min-complexity' => 50,
- 'min-architecture' => 75,
- 'min-style' => 95,
- 'disable-security-check' => false,
- ],
- 'threads' => null
-];
diff --git a/phpstan.neon b/phpstan.neon
deleted file mode 100644
index c3392e9..0000000
--- a/phpstan.neon
+++ /dev/null
@@ -1,7 +0,0 @@
-parameters:
- level: max
- paths:
- - src
- - tests
- ignoreErrors:
- - '#Method .* has parameter \$.* with no value type specified in iterable type array.#'
diff --git a/phpunit.xml b/phpunit.xml
deleted file mode 100644
index ba8e7af..0000000
--- a/phpunit.xml
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- tests
-
-
-
-
-
- src
-
-
-
diff --git a/psalm.xml b/psalm.xml
deleted file mode 100644
index f0c90a3..0000000
--- a/psalm.xml
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Cache/PhpFileCache.php b/src/Cache/PhpFileCache.php
new file mode 100644
index 0000000..4944360
--- /dev/null
+++ b/src/Cache/PhpFileCache.php
@@ -0,0 +1,131 @@
+ $variables Variable name → raw string value.
+ * @param string $sourceHash MD5 of the source .env file(s) for invalidation.
+ */
+ public function dump(string $path, array $variables, string $sourceHash = ''): void
+ {
+ $metadata = [
+ '__metadata' => [
+ 'generated_at' => date('c'),
+ 'source_hash' => $sourceHash,
+ 'generator' => 'KaririCode\\Dotenv v4.x',
+ ],
+ ];
+
+ $export = var_export(array_merge($metadata, $variables), true);
+
+ $content = <<|null Variables, or null if cache is missing/stale.
+ */
+ public function load(string $path, string $expectedHash = ''): ?array
+ {
+ if (! is_file($path) || ! is_readable($path)) {
+ return null;
+ }
+
+ $data = include $path;
+
+ if (! \is_array($data)) {
+ return null;
+ }
+
+ /** @var array $data */
+
+ // Validate source hash for staleness detection
+ if ($expectedHash !== '') {
+ $storedHash = isset($data['__metadata']) && \is_array($data['__metadata'])
+ ? ($data['__metadata']['source_hash'] ?? '')
+ : '';
+
+ if ($storedHash !== $expectedHash) {
+ return null;
+ }
+ }
+
+ // Strip metadata before returning
+ unset($data['__metadata']);
+
+ /** @var array $data */
+ return $data;
+ }
+
+ public function clear(string $path): void
+ {
+ if (is_file($path)) {
+ unlink($path);
+
+ if (\function_exists('opcache_invalidate')) {
+ opcache_invalidate($path, true);
+ }
+ }
+ }
+
+ /**
+ * Computes a hash of the given file paths' contents for cache invalidation.
+ *
+ * @param list $filePaths
+ */
+ public function computeSourceHash(array $filePaths): string
+ {
+ $context = hash_init('md5');
+
+ foreach ($filePaths as $filePath) {
+ if (is_file($filePath)) {
+ hash_update($context, (string) filemtime($filePath));
+ hash_update($context, (string) filesize($filePath));
+ }
+ }
+
+ return hash_final($context);
+ }
+}
diff --git a/src/Contract/Dotenv.php b/src/Contract/Dotenv.php
deleted file mode 100644
index 806229d..0000000
--- a/src/Contract/Dotenv.php
+++ /dev/null
@@ -1,17 +0,0 @@
- Variable name → raw string value (after quote stripping and interpolation).
+ *
+ * @throws ParseException On syntax errors (invalid name, unterminated quote, circular reference).
+ */
+ public function parse(string $content, string $filePath = '.env', bool $strictNames = false): array
+ {
+ /** @var array $variables */
+ $variables = [];
+ /** @var list $lines */
+ $lines = $this->normalizeLines($content);
+ $lineNumber = 0;
+
+ while ($lineNumber < \count($lines)) {
+ /** @var int<0, max> $lineNumber */
+ $line = $lines[$lineNumber];
+ // Increment first: $lineNumber becomes 1-indexed (for error messages) and
+ // simultaneously equals the 0-indexed position of the next line (for multiline
+ // continuation in parseDoubleQuoted). This dual purpose is intentional.
+ ++$lineNumber;
+
+ $trimmed = trim($line);
+
+ // Skip empty lines and comments
+ if ($trimmed === '' || str_starts_with($trimmed, '#')) {
+ continue;
+ }
+
+ // Strip optional 'export' prefix
+ if (str_starts_with($trimmed, 'export ')) {
+ $trimmed = ltrim(substr($trimmed, 7));
+ }
+
+ // Find the = separator
+ $equalsPos = strpos($trimmed, '=');
+ if ($equalsPos === false) {
+ // Bare variable name without value — treat as empty string
+ $name = $trimmed;
+ $rawValue = '';
+ } else {
+ $name = rtrim(substr($trimmed, 0, $equalsPos));
+ $rawValue = ltrim(substr($trimmed, $equalsPos + 1));
+ }
+
+ // Validate variable name
+ $namePattern = $strictNames ? self::STRICT_NAME_PATTERN : self::VARIABLE_NAME_PATTERN;
+ if (preg_match($namePattern, $name) !== 1) {
+ throw ParseException::invalidVariableName($name, $lineNumber, $filePath, $strictNames);
+ }
+
+ // Parse value (handles quoting and multiline)
+ [$parsedValue, $consumed] = $this->parseValue($rawValue, $lines, $lineNumber, $filePath, $variables);
+ $lineNumber += $consumed;
+
+ $variables[$name] = $parsedValue;
+ }
+
+ return $variables;
+ }
+
+ // ── Value Parsing ─────────────────────────────────────────────────
+
+ /**
+ * Parses the value portion of a KEY=VALUE line.
+ *
+ * @param list $lines All source lines.
+ * @param array $resolvedVariables Already-parsed variables for interpolation.
+ *
+ * @return array{0: string, 1: int} Tuple of [parsed value, additional lines consumed].
+ */
+ private function parseValue(
+ string $rawValue,
+ array $lines,
+ int $currentLine,
+ string $filePath,
+ array $resolvedVariables,
+ ): array {
+ if ($rawValue === '') {
+ return ['', 0];
+ }
+
+ $firstChar = $rawValue[0];
+
+ // Double-quoted value — supports escapes, interpolation, multiline
+ if ($firstChar === '"') {
+ return $this->parseDoubleQuoted($rawValue, $lines, $currentLine, $filePath, $resolvedVariables);
+ }
+
+ // Single-quoted value — literal, no escapes, no interpolation
+ if ($firstChar === "'") {
+ return $this->parseSingleQuoted($rawValue, $currentLine, $filePath);
+ }
+
+ // Unquoted value — strip inline comments, apply interpolation
+ return [$this->parseUnquoted($rawValue, $resolvedVariables), 0];
+ }
+
+ /**
+ * @param list $lines
+ * @param array $resolvedVariables
+ * @return array{0: string, 1: int}
+ */
+ private function parseDoubleQuoted(
+ string $rawValue,
+ array $lines,
+ int $currentLine,
+ string $filePath,
+ array $resolvedVariables,
+ ): array {
+ // Remove opening quote
+ $value = substr($rawValue, 1);
+ $result = '';
+ $extraLines = 0;
+
+ while (true) {
+ $length = \strlen($value);
+
+ for ($i = 0; $i < $length; ++$i) {
+ $char = $value[$i];
+
+ // Escape sequence
+ if ($char === '\\' && $i + 1 < $length) {
+ $next = $value[$i + 1];
+ $result .= match ($next) {
+ 'n' => "\n",
+ 'r' => "\r",
+ 't' => "\t",
+ '"' => '"',
+ '\\' => '\\',
+ '$' => '$',
+ default => '\\' . $next,
+ };
+ ++$i;
+
+ continue;
+ }
+
+ // Closing quote found
+ if ($char === '"') {
+ $result = $this->expandVariables($result, $resolvedVariables);
+
+ return [$result, $extraLines];
+ }
+
+ $result .= $char;
+ }
+
+ // Value continues on next line
+ $nextLineIndex = $currentLine + $extraLines;
+ if ($nextLineIndex >= \count($lines)) {
+ throw ParseException::unterminatedQuote($currentLine, $filePath);
+ }
+
+ $result .= "\n";
+ /** @var int<0, max> $nextLineIndex */
+ $value = $lines[$nextLineIndex];
+ ++$extraLines;
+ }
+ }
+
+ /**
+ * @return array{0: string, 1: int}
+ */
+ private function parseSingleQuoted(string $rawValue, int $currentLine, string $filePath): array
+ {
+ $closingPos = strpos($rawValue, "'", 1);
+
+ if ($closingPos === false) {
+ throw ParseException::unterminatedQuote($currentLine, $filePath);
+ }
+
+ return [substr($rawValue, 1, $closingPos - 1), 0];
+ }
+
+ /** @param array $resolvedVariables */
+ private function parseUnquoted(string $rawValue, array $resolvedVariables): string
+ {
+ // Strip inline comment: look for # preceded by whitespace, outside any quoting
+ $value = preg_replace('/\s+#.*$/', '', $rawValue) ?? $rawValue;
+ $value = trim($value);
+
+ return $this->expandVariables($value, $resolvedVariables);
+ }
+
+ // ── Variable Expansion ────────────────────────────────────────────
+
+ /**
+ * Expands ${VAR} and $VAR references against resolved variables and the environment.
+ *
+ * Resolution order: parsed variables → $_ENV → $_SERVER → empty string.
+ *
+ * @param array $resolvedVariables
+ */
+ private function expandVariables(string $value, array $resolvedVariables): string
+ {
+ // Expand ${VAR:-default} and ${VAR:+alternate} syntax
+ $value = preg_replace_callback(
+ '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:[-+])(.*?))?\}/',
+ static function (array $matches) use ($resolvedVariables): string {
+ $name = $matches[1];
+ $operator = $matches[2] ?? '';
+ $operand = $matches[3] ?? '';
+
+ $resolved = $resolvedVariables[$name]
+ ?? (isset($_ENV[$name]) && \is_string($_ENV[$name]) ? $_ENV[$name] : null)
+ ?? (isset($_SERVER[$name]) && \is_string($_SERVER[$name]) ? $_SERVER[$name] : null)
+ ?? null;
+
+ return (string) match ($operator) {
+ // ${VAR:+alternate} — use alternate if VAR is set and non-empty
+ ':+' => ($resolved !== null && $resolved !== '') ? $operand : '',
+ // ${VAR:-default} — use default if VAR is unset or empty
+ ':-' => ($resolved !== null && $resolved !== '') ? $resolved : $operand,
+ // ${VAR} — plain substitution
+ default => $resolved ?? '',
+ };
+ },
+ $value,
+ ) ?? $value;
+
+ // Expand bare $VAR syntax
+ $value = preg_replace_callback(
+ '/\$([A-Za-z_][A-Za-z0-9_]*)/',
+ static function (array $matches) use ($resolvedVariables): string {
+ $name = $matches[1];
+
+ return $resolvedVariables[$name]
+ ?? (isset($_ENV[$name]) && \is_string($_ENV[$name]) ? $_ENV[$name] : null)
+ ?? (isset($_SERVER[$name]) && \is_string($_SERVER[$name]) ? $_SERVER[$name] : null)
+ ?? '';
+ },
+ $value,
+ ) ?? $value;
+
+ return $value;
+ }
+
+ // ── Line Normalization ────────────────────────────────────────────
+
+ /**
+ * Splits content into lines, normalizing line endings (CRLF → LF).
+ *
+ * @return list
+ */
+ private function normalizeLines(string $content): array
+ {
+ $normalized = str_replace("\r\n", "\n", $content);
+ $normalized = str_replace("\r", "\n", $normalized);
+
+ return explode("\n", $normalized);
+ }
+}
diff --git a/src/Dotenv.php b/src/Dotenv.php
index 3faef46..64b751e 100644
--- a/src/Dotenv.php
+++ b/src/Dotenv.php
@@ -4,53 +4,561 @@
namespace KaririCode\Dotenv;
-use KaririCode\Dotenv\Contract\Dotenv as DotenvContract;
-use KaririCode\Dotenv\Contract\Loader;
-use KaririCode\Dotenv\Contract\Parser;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
-use KaririCode\Dotenv\Contract\Type\TypeDetector;
-use KaririCode\Dotenv\Contract\Type\TypeSystem;
-use KaririCode\Dotenv\Type\DotenvTypeSystem;
-
-class Dotenv implements DotenvContract
+use KaririCode\Dotenv\Cache\PhpFileCache;
+use KaririCode\Dotenv\Contract\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeDetector;
+use KaririCode\Dotenv\Contract\VariableProcessor;
+use KaririCode\Dotenv\Core\DotenvParser;
+use KaririCode\Dotenv\Enum\LoadMode;
+use KaririCode\Dotenv\Enum\ValueType;
+use KaririCode\Dotenv\Exception\FileNotFoundException;
+use KaririCode\Dotenv\Exception\ImmutableException;
+use KaririCode\Dotenv\Exception\ValidationException;
+use KaririCode\Dotenv\Schema\SchemaParser;
+use KaririCode\Dotenv\Security\Encryptor;
+use KaririCode\Dotenv\Type\TypeSystem;
+use KaririCode\Dotenv\Validation\EnvironmentValidator;
+use KaririCode\Dotenv\ValueObject\DotenvConfiguration;
+use KaririCode\Dotenv\ValueObject\EnvironmentVariable;
+
+/**
+ * Production-grade .env file loader for the KaririCode Framework.
+ *
+ * The first and only PHP dotenv that combines: auto type casting,
+ * native AES-256-GCM encryption, OPcache-friendly caching,
+ * fluent validation DSL, environment-aware cascade loading,
+ * and variable processors — all with zero external dependencies.
+ *
+ * ## Quick Start
+ *
+ * ```php
+ * $dotenv = new Dotenv('/path/to/project');
+ * $dotenv->load();
+ *
+ * $debug = env('APP_DEBUG'); // bool: true
+ * $port = env('DB_PORT'); // int: 5432
+ * ```
+ *
+ * ## Encrypted .env
+ *
+ * ```php
+ * $config = new DotenvConfiguration(
+ * encryptionKey: $_SERVER['DOTENV_PRIVATE_KEY'] ?? null,
+ * );
+ * $dotenv = new Dotenv('/path/to/project', $config);
+ * $dotenv->load(); // Transparently decrypts "encrypted:..." values
+ * ```
+ *
+ * ## Validation DSL
+ *
+ * ```php
+ * $dotenv->validate()
+ * ->required('DB_HOST', 'DB_PORT')
+ * ->isInteger('DB_PORT')->between(1, 65535)
+ * ->isBoolean('APP_DEBUG')
+ * ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ * ->assert();
+ * ```
+ *
+ * ## Environment-Aware Loading
+ *
+ * ```php
+ * $dotenv = new Dotenv('/path/to/project');
+ * $dotenv->bootEnv(); // .env → .env.local → .env.{APP_ENV} → .env.{APP_ENV}.local
+ * ```
+ *
+ * ARFA 1.3 Compliance:
+ * - P1 Immutable State: Configuration and EnvironmentVariable are readonly.
+ * - P3 Adaptive Context: TypeSystem + processors are extensible.
+ * - P4 Protocol Agnostic: Parser accepts any string content.
+ * - P5 Continuous Observability: debug() provides full variable introspection.
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final class Dotenv
{
+ private readonly DotenvParser $parser;
+ private readonly TypeSystem $typeSystem;
+ private readonly DotenvConfiguration $configuration;
+ private readonly string $directory;
+
+ /** @var list Resolved file paths to load. */
+ private array $filePaths = [];
+
+ /** @var array Loaded variables keyed by name. */
+ private array $variables = [];
+
+ /** @var array> Pattern → processors. */
+ private array $processors = [];
+
+ private ?Encryptor $encryptor = null;
+ private bool $loaded = false;
+
public function __construct(
- private Parser $parser,
- private Loader $loader,
- private TypeSystem $typeSystem = new DotenvTypeSystem()
+ string $directory,
+ ?DotenvConfiguration $configuration = null,
+ string ...$fileNames,
) {
+ $this->directory = rtrim($directory, DIRECTORY_SEPARATOR);
+ $this->parser = new DotenvParser();
+ $this->typeSystem = new TypeSystem();
+ $this->configuration = $configuration ?? new DotenvConfiguration();
+
+ // Initialize encryptor if key provided via config or environment
+ $encryptionKey = $this->configuration->encryptionKey
+ ?? (\is_string($_SERVER['DOTENV_PRIVATE_KEY'] ?? null) ? $_SERVER['DOTENV_PRIVATE_KEY'] : null)
+ ?? (\is_string($_ENV['DOTENV_PRIVATE_KEY'] ?? null) ? $_ENV['DOTENV_PRIVATE_KEY'] : null)
+ ?? null;
+
+ if ($encryptionKey !== null && $encryptionKey !== '') {
+ $this->encryptor = new Encryptor($encryptionKey);
+ }
+
+ if ($fileNames === []) {
+ $fileNames = ['.env'];
+ }
+
+ foreach ($fileNames as $fileName) {
+ $this->filePaths[] = $this->directory . DIRECTORY_SEPARATOR . $fileName;
+ }
}
+ // ── Public API ────────────────────────────────────────────────────
+
+ /**
+ * Loads all configured .env files. If a cache file exists and is fresh,
+ * loads from cache instead (zero parsing cost via OPcache).
+ *
+ * @throws FileNotFoundException When a required file does not exist.
+ * @throws ImmutableException When a variable conflicts in Immutable mode.
+ */
public function load(): void
{
- $content = $this->loader->load();
- $parsed = $this->parser->parse($content);
+ if ($this->loadFromCache()) {
+ $this->loaded = true;
+
+ return;
+ }
+
+ foreach ($this->filePaths as $filePath) {
+ $this->loadFile($filePath, required: true);
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Loads .env files that exist, silently skipping missing ones.
+ */
+ public function safeLoad(): void
+ {
+ if ($this->loadFromCache()) {
+ $this->loaded = true;
+
+ return;
+ }
+
+ foreach ($this->filePaths as $filePath) {
+ $this->loadFile($filePath, required: false);
+ }
+
+ $this->loaded = true;
+ }
+
+ /**
+ * Environment-aware cascade loading (inspired by Symfony's bootEnv).
+ *
+ * Loads files in order:
+ * 1. `.env` — base defaults (committed)
+ * 2. `.env.local` — local overrides (gitignored)
+ * 3. `.env.{env}` — environment-specific defaults (committed)
+ * 4. `.env.{env}.local` — environment-specific local overrides (gitignored)
+ *
+ * The environment name is resolved from $environmentName parameter,
+ * configuration's environmentName, the APP_ENV variable, or defaults to "dev".
+ */
+ public function bootEnv(?string $environmentName = null): void
+ {
+ if ($this->loadFromCache()) {
+ $this->loaded = true;
+
+ return;
+ }
+
+ $basePath = $this->directory . DIRECTORY_SEPARATOR . '.env';
+
+ // 1. Base .env
+ $this->loadFile($basePath, required: false);
+
+ // 2. .env.local (always loaded for local overrides)
+ $this->loadFile($basePath . '.local', required: false);
+
+ // Determine environment name
+ $envName = $environmentName
+ ?? $this->configuration->environmentName
+ ?? $this->resolveRawValue('APP_ENV')
+ ?? 'dev';
+
+ // 3. .env.{env} (committed env-specific defaults)
+ $this->loadFile("{$basePath}.{$envName}", required: false);
+
+ // 4. .env.{env}.local (skip for "test" to ensure reproducibility)
+ if ($envName !== 'test') {
+ $this->loadFile("{$basePath}.{$envName}.local", required: false);
+ }
+
+ $this->loaded = true;
+ }
+
+ // ── Validation ────────────────────────────────────────────────────
+
+ /**
+ * Simple required-presence check (backward compatible with v4.0).
+ *
+ * @throws ValidationException When any required variable is missing.
+ */
+ public function required(string ...$names): void
+ {
+ $missing = array_filter(
+ $names,
+ fn (string $name): bool => ! isset($this->variables[$name])
+ && ! isset($_ENV[$name])
+ && ! isset($_SERVER[$name]),
+ );
+
+ if ($missing !== []) {
+ throw ValidationException::missingRequired(array_values($missing));
+ }
+ }
+
+ /**
+ * Returns a fluent validation builder. Call ->assert() to execute.
+ *
+ * ```php
+ * $dotenv->validate()
+ * ->required('DB_HOST', 'DB_PORT')
+ * ->isInteger('DB_PORT')->between(1, 65535)
+ * ->assert();
+ * ```
+ */
+ public function validate(): EnvironmentValidator
+ {
+ return new EnvironmentValidator(
+ fn (string $name): ?string => $this->resolveRawValue($name),
+ );
+ }
+
+ /**
+ * Loads and applies a .env.schema file for declarative validation.
+ *
+ * @throws ValidationException On any schema violation.
+ * @throws FileNotFoundException When the schema file is missing.
+ */
+ public function loadWithSchema(string $schemaPath): void
+ {
+ if (! $this->loaded) {
+ $this->load();
+ }
+
+ if (! is_file($schemaPath) || ! is_readable($schemaPath)) {
+ throw FileNotFoundException::forPath($schemaPath);
+ }
+
+ $content = file_get_contents($schemaPath);
+
+ if ($content === false) {
+ throw FileNotFoundException::forPath($schemaPath);
+ }
+
+ $schemaParser = new SchemaParser();
+ $schema = $schemaParser->parse($content);
+ $validator = $this->validate();
+ $schemaParser->applyToValidator($schema, $validator);
+ $validator->assert();
+ }
+
+ // ── Accessors ─────────────────────────────────────────────────────
+
+ public function get(string $name, mixed $default = null): mixed
+ {
+ return isset($this->variables[$name])
+ ? $this->variables[$name]->value
+ : $default;
+ }
+
+ /** @return array */
+ public function variables(): array
+ {
+ return $this->variables;
+ }
+
+ public function isLoaded(): bool
+ {
+ return $this->loaded;
+ }
+
+ // ── Debug & Introspection ─────────────────────────────────────────
+
+ /**
+ * Returns a debug report with source tracking, types, and override info.
+ *
+ * @return array
+ */
+ public function debug(): array
+ {
+ $report = [];
+
+ foreach ($this->variables as $name => $var) {
+ $report[$name] = [
+ 'source' => $var->source,
+ 'rawValue' => $var->rawValue,
+ 'type' => $var->type->name,
+ 'value' => $var->value,
+ 'overridden' => $var->overridden,
+ ];
+ }
+
+ return $report;
+ }
+
+ // ── Cache ─────────────────────────────────────────────────────────
+
+ /**
+ * Dumps all loaded variables to a PHP array cache file.
+ * OPcache compiles this once — subsequent requests load from shared memory.
+ */
+ public function dumpCache(string $path): void
+ {
+ $cache = new PhpFileCache();
+ $rawVariables = [];
+
+ foreach ($this->variables as $name => $var) {
+ $rawVariables[$name] = $var->rawValue;
+ }
+
+ $hash = $cache->computeSourceHash($this->filePaths);
+ $cache->dump($path, $rawVariables, $hash);
+ }
+
+ public function clearCache(string $path): void
+ {
+ new PhpFileCache()->clear($path);
+ }
+
+ // ── Extension Points ──────────────────────────────────────────────
+
+ public function addTypeDetector(TypeDetector $detector): void
+ {
+ $this->typeSystem->addDetector($detector);
+ }
+
+ public function addTypeCaster(ValueType $type, TypeCaster $caster): void
+ {
+ $this->typeSystem->addCaster($type, $caster);
+ }
+
+ /**
+ * Registers a post-load processor for variables matching a name or glob pattern.
+ *
+ * @param string $pattern Exact name or glob (e.g., "*_URL", "ALLOWED_IPS").
+ * @param VariableProcessor $processor Transformer to apply after type casting.
+ */
+ public function addProcessor(string $pattern, VariableProcessor $processor): void
+ {
+ $this->processors[$pattern][] = $processor;
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────
+
+ private function loadFromCache(): bool
+ {
+ $cachePath = $this->configuration->cachePath;
+
+ if ($cachePath === null) {
+ return false;
+ }
+
+ $cache = new PhpFileCache();
+ $hash = $cache->computeSourceHash($this->filePaths);
+ $cached = $cache->load($cachePath, $hash);
+
+ if ($cached === null) {
+ return false;
+ }
+
+ foreach ($cached as $name => $rawValue) {
+ $this->setVariable($name, $rawValue, 'cache');
+ }
+
+ return true;
+ }
+
+ private function loadFile(string $filePath, bool $required): void
+ {
+ if (! is_file($filePath) || ! is_readable($filePath)) {
+ if ($required) {
+ throw FileNotFoundException::forPath($filePath);
+ }
+
+ return;
+ }
+
+ $content = file_get_contents($filePath);
+
+ if ($content === false) {
+ if ($required) {
+ throw FileNotFoundException::forPath($filePath);
+ }
+
+ return;
+ }
+
+ $rawVariables = $this->parser->parse(
+ $content,
+ $filePath,
+ $this->configuration->strictNames,
+ );
+
+ $source = basename($filePath);
+
+ foreach ($rawVariables as $name => $rawValue) {
+ $this->setVariable($name, $rawValue, $source);
+ }
+ }
+
+ private function setVariable(string $name, string $rawValue, string $source): void
+ {
+ // Allow/Deny list filtering
+ if (! $this->isAllowed($name)) {
+ return;
+ }
+
+ $alreadyExists = isset($_ENV[$name]) || isset($_SERVER[$name]) || getenv($name) !== false;
+
+ // Immutable: throw if variable existed BEFORE this instance loaded it
+ if ($this->configuration->loadMode === LoadMode::Immutable
+ && $alreadyExists
+ && ! isset($this->variables[$name])
+ ) {
+ throw ImmutableException::alreadyDefined($name);
+ }
+
+ // SkipExisting: silently skip pre-existing environment variables
+ if ($this->configuration->loadMode === LoadMode::SkipExisting && $alreadyExists) {
+ return;
+ }
+
+ // Decrypt encrypted values transparently
+ $decryptedValue = $rawValue;
+ if ($this->encryptor !== null && Encryptor::isEncrypted($rawValue)) {
+ $decryptedValue = $this->encryptor->decrypt($rawValue);
+ }
- if (!empty($parsed)) {
- foreach ($parsed as $key => $value) {
- $processedValue = $this->typeSystem->processValue($value);
- $this->setEnvironmentVariable($key, $processedValue);
+ // Type detection and casting
+ $type = ValueType::String;
+ $typedValue = $decryptedValue;
+
+ if ($this->configuration->typeCasting) {
+ $type = $this->typeSystem->detect($decryptedValue);
+ $typedValue = $this->typeSystem->cast($decryptedValue, $type);
+ }
+
+ // Apply registered processors
+ $typedValue = $this->applyProcessors($name, $decryptedValue, $typedValue);
+
+ $overridden = isset($this->variables[$name]);
+ $this->variables[$name] = new EnvironmentVariable(
+ $name,
+ $decryptedValue,
+ $type,
+ $typedValue,
+ $source,
+ $overridden,
+ );
+
+ // Populate environment with decrypted raw string
+ if ($this->configuration->populateEnv) {
+ $_ENV[$name] = $decryptedValue;
+ }
+
+ if ($this->configuration->populateServer) {
+ $_SERVER[$name] = $decryptedValue;
+ }
+
+ if ($this->configuration->usePutenv) {
+ putenv("{$name}={$decryptedValue}");
+ }
+ }
+
+ private function isAllowed(string $name): bool
+ {
+ // Deny list takes precedence
+ foreach ($this->configuration->denyList as $pattern) {
+ if ($this->matchGlob($pattern, $name)) {
+ return false;
+ }
+ }
+
+ // Empty allow list means everything allowed
+ if ($this->configuration->allowList === []) {
+ return true;
+ }
+
+ foreach ($this->configuration->allowList as $pattern) {
+ if ($this->matchGlob($pattern, $name)) {
+ return true;
}
}
+
+ return false;
}
- public function addTypeDetector(TypeDetector $detector): self
+ private function matchGlob(string $pattern, string $name): bool
{
- $this->typeSystem->registerDetector($detector);
+ if ($pattern === $name) {
+ return true;
+ }
- return $this;
+ $regex = '/\A' . str_replace(
+ ['\*', '\?'],
+ ['.*', '.'],
+ preg_quote($pattern, '/'),
+ ) . '\z/';
+
+ return preg_match($regex, $name) === 1;
}
- public function addTypeCaster(string $type, TypeCaster $caster): self
+ private function applyProcessors(string $name, string $rawValue, mixed $typedValue): mixed
{
- $this->typeSystem->registerCaster($type, $caster);
+ foreach ($this->processors as $pattern => $processorList) {
+ if ($this->matchGlob($pattern, $name)) {
+ foreach ($processorList as $processor) {
+ $typedValue = $processor->process($rawValue, $typedValue);
+ }
+ }
+ }
- return $this;
+ return $typedValue;
}
- private function setEnvironmentVariable(string $key, mixed $value): void
+ private function resolveRawValue(string $name): ?string
{
- $_ENV[$key] = $value;
- $_SERVER[$key] = $value;
+ if (isset($this->variables[$name])) {
+ return $this->variables[$name]->rawValue;
+ }
+
+ $envVal = $_ENV[$name] ?? null;
+ $serverVal = $_SERVER[$name] ?? null;
+
+ if (\is_string($envVal)) {
+ return $envVal;
+ }
+
+ if (\is_string($serverVal)) {
+ return $serverVal;
+ }
+
+ return null;
}
}
diff --git a/src/DotenvFactory.php b/src/DotenvFactory.php
deleted file mode 100644
index eab9a53..0000000
--- a/src/DotenvFactory.php
+++ /dev/null
@@ -1,20 +0,0 @@
- */
+ private array $errors = [];
+
+ /** @param list $missing */
+ public static function missingRequired(array $missing): self
+ {
+ return new self(
+ 'Missing required environment variables: ' . implode(', ', $missing),
+ );
+ }
+
+ /** @param list $errors */
+ public static function batchErrors(array $errors): self
+ {
+ $exception = new self(
+ "Environment validation failed:\n- " . implode("\n- ", $errors),
+ );
+ $exception->errors = $errors;
+
+ return $exception;
+ }
+
+ public static function schemaViolation(string $message): self
+ {
+ return new self("Schema validation failed: {$message}");
+ }
+
+ /** @return list */
+ public function errors(): array
+ {
+ return $this->errors;
+ }
+}
diff --git a/src/Loader/ArrayLoader.php b/src/Loader/ArrayLoader.php
deleted file mode 100644
index e9a1cb0..0000000
--- a/src/Loader/ArrayLoader.php
+++ /dev/null
@@ -1,27 +0,0 @@
-variables = $variables;
- }
-
- public function load(): string
- {
- $output = '';
- foreach ($this->variables as $key => $value) {
- $output .= "$key=$value\n";
- }
-
- return $output;
- }
-}
diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php
deleted file mode 100644
index 05031ac..0000000
--- a/src/Loader/FileLoader.php
+++ /dev/null
@@ -1,38 +0,0 @@
-filePath = $filePath;
- }
-
- public function load(): string
- {
- if (!file_exists($this->filePath)) {
- throw new InvalidFileException(sprintf('The environment file %s does not exist.', $this->filePath));
- }
-
- $contents = $this->getFileContents($this->filePath);
-
- if (false === $contents) {
- throw new InvalidFileException(sprintf('Unable to read the environment file at %s.', $this->filePath));
- }
-
- return $contents;
- }
-
- protected function getFileContents(string $filePath): string|false
- {
- return file_get_contents($filePath);
- }
-}
diff --git a/src/Parser/AbstractParser.php b/src/Parser/AbstractParser.php
deleted file mode 100644
index 163511c..0000000
--- a/src/Parser/AbstractParser.php
+++ /dev/null
@@ -1,42 +0,0 @@
-splitLines($content);
- $parsedValues = $this->parseLines($lines);
-
- return $this->interpolateValues($parsedValues);
- }
-
- abstract protected function parseLines(array $lines): array;
-
- protected function splitLines(string $content): array
- {
- return preg_split('/\r\n|\r|\n/', $content);
- }
-
- protected function interpolateValues(array $parsedValues): array
- {
- return array_map(
- fn ($value) => $this->interpolateValue($value, $parsedValues),
- $parsedValues
- );
- }
-
- protected function interpolateValue(string $value, array $parsedValues): string
- {
- return preg_replace_callback(
- '/\${([A-Z0-9_]+)}/',
- fn ($matches) => $parsedValues[$matches[1]] ?? $matches[0],
- $value
- );
- }
-}
diff --git a/src/Parser/DefaultParser.php b/src/Parser/DefaultParser.php
deleted file mode 100644
index 437990a..0000000
--- a/src/Parser/DefaultParser.php
+++ /dev/null
@@ -1,27 +0,0 @@
-isValidSetter($line)) {
- [$key, $value] = $this->parseEnvironmentVariable($line);
- if ($this->isValidKey($key)) {
- $parsedValues[$key] = $this->sanitizeValue($value);
- }
- }
- }
-
- return $parsedValues;
- }
-}
diff --git a/src/Parser/StrictParser.php b/src/Parser/StrictParser.php
deleted file mode 100644
index 096322d..0000000
--- a/src/Parser/StrictParser.php
+++ /dev/null
@@ -1,54 +0,0 @@
-isValidSetter($line)) {
- [$key, $value] = $this->parseEnvironmentVariable($line);
- $this->validateVariableName($key);
- $parsedValues[$key] = $this->sanitizeValue($value);
- }
- }
-
- return $parsedValues;
- }
-
- private function validateVariableName(?string $name): void
- {
- if (!$this->isValidKey($name)) {
- throw new InvalidValueException('Empty variable name');
- }
-
- if ($this->containsInvalidCharacters($name)) {
- throw new InvalidValueException('Invalid character in variable name');
- }
-
- if (!$this->startsWithValidCharacter($name)) {
- throw new InvalidValueException('Variable name must start with a letter or underscore');
- }
- }
-
- private function containsInvalidCharacters(string $name): bool
- {
- return false !== strpbrk($name, self::INVALID_NAME_CHARS);
- }
-
- private function startsWithValidCharacter(string $name): bool
- {
- return 1 === preg_match('/^[a-zA-Z_]/', $name);
- }
-}
diff --git a/src/Parser/Trait/CommonParserFunctionality.php b/src/Parser/Trait/CommonParserFunctionality.php
deleted file mode 100644
index 14ca00a..0000000
--- a/src/Parser/Trait/CommonParserFunctionality.php
+++ /dev/null
@@ -1,60 +0,0 @@
-isComment($trimmedLine) && $this->containsSetterChar($trimmedLine);
- }
-
- protected function isComment(string $line): bool
- {
- return 1 === preg_match('/^\s*#/', $line);
- }
-
- protected function containsSetterChar(string $line): bool
- {
- return str_contains($line, '=');
- }
-
- protected function parseEnvironmentVariable(string $line): array
- {
- $parts = explode('=', $line, 2);
- if (2 !== count($parts)) {
- return [null, null];
- }
-
- return [trim($parts[0]) ?: null, trim($parts[1])];
- }
-
- protected function sanitizeValue(string $value): string
- {
- $value = trim($value);
-
- return $this->removeQuotes($value);
- }
-
- protected function removeQuotes(string $value): string
- {
- if (strlen($value) > 1) {
- $first = $value[0];
- $last = $value[strlen($value) - 1];
- if (('"' === $first && '"' === $last) || ("'" === $first && "'" === $last)) {
- return substr($value, 1, -1);
- }
- }
-
- return $value;
- }
-
- protected function isValidKey(?string $key): bool
- {
- return null !== $key && '' !== $key;
- }
-}
diff --git a/src/Processor/Base64DecodeProcessor.php b/src/Processor/Base64DecodeProcessor.php
new file mode 100644
index 0000000..1391b22
--- /dev/null
+++ b/src/Processor/Base64DecodeProcessor.php
@@ -0,0 +1,22 @@
+ */
+ #[\Override]
+ public function process(string $rawValue, mixed $typedValue): array
+ {
+ if (trim($rawValue) === '') {
+ return [];
+ }
+
+ $separator = $this->separator !== '' ? $this->separator : ',';
+
+ return array_map(trim(...), explode($separator, $rawValue));
+ }
+}
diff --git a/src/Processor/TrimProcessor.php b/src/Processor/TrimProcessor.php
new file mode 100644
index 0000000..f2bee77
--- /dev/null
+++ b/src/Processor/TrimProcessor.php
@@ -0,0 +1,21 @@
+characters);
+ }
+}
diff --git a/src/Processor/UrlNormalizerProcessor.php b/src/Processor/UrlNormalizerProcessor.php
new file mode 100644
index 0000000..c8cae7a
--- /dev/null
+++ b/src/Processor/UrlNormalizerProcessor.php
@@ -0,0 +1,16 @@
+> Variable name → directive → value.
+ */
+ public function parse(string $content): array
+ {
+ $schema = [];
+ $currentSection = null;
+
+ foreach (explode("\n", $content) as $line) {
+ $line = trim($line);
+
+ // Skip empty lines and comments
+ if ($line === '' || $line[0] === '#' || $line[0] === ';') {
+ continue;
+ }
+
+ // Section header: [VAR_NAME]
+ if (preg_match('/^\[([A-Za-z_][A-Za-z0-9_]*)\]$/', $line, $matches)) {
+ $currentSection = $matches[1];
+ $schema[$currentSection] ??= [];
+
+ continue;
+ }
+
+ // Directive: key = value
+ if ($currentSection !== null && str_contains($line, '=')) {
+ $eqPos = strpos($line, '=');
+
+ if ($eqPos === false) {
+ continue;
+ }
+
+ $key = trim(substr($line, 0, $eqPos));
+ $value = trim(substr($line, $eqPos + 1));
+ $schema[$currentSection][$key] = $value;
+ }
+ }
+
+ return $schema;
+ }
+
+ /**
+ * Applies parsed schema rules to an EnvironmentValidator.
+ *
+ * @param array> $schema
+ */
+ public function applyToValidator(
+ array $schema,
+ EnvironmentValidator $validator,
+ ): void {
+ foreach ($schema as $variableName => $directives) {
+ $isRequired = $this->boolDirective($directives, 'required');
+
+ if ($isRequired) {
+ $validator->required($variableName);
+ } else {
+ $validator->ifPresent($variableName);
+ }
+
+ // notEmpty
+ if ($this->boolDirective($directives, 'notEmpty')) {
+ $validator->notEmpty($variableName);
+ }
+
+ // Type-based rules
+ $type = $directives['type'] ?? null;
+ if ($type !== null) {
+ match ($type) {
+ 'integer' => $validator->isInteger($variableName),
+ 'boolean' => $validator->isBoolean($variableName),
+ 'numeric' => $validator->isNumeric($variableName),
+ 'email' => $validator->email($variableName),
+ 'url' => $validator->url($variableName),
+ 'string' => null, // No additional rule needed
+ default => throw ValidationException::schemaViolation(
+ "Unknown type '{$type}' for [{$variableName}].",
+ ),
+ };
+ }
+
+ // Numeric range (min/max)
+ $min = isset($directives['min']) ? (float) $directives['min'] : null;
+ $max = isset($directives['max']) ? (float) $directives['max'] : null;
+
+ if ($min !== null && $max !== null) {
+ $validator->between($min, $max);
+ }
+
+ // Allowed values
+ if (isset($directives['allowed'])) {
+ $allowed = array_map(trim(...), explode(',', $directives['allowed']));
+ $validator->allowedValues($variableName, $allowed);
+ }
+
+ // Regex
+ if (isset($directives['regex'])) {
+ $validator->matchesRegex($variableName, $directives['regex']);
+ }
+ }
+ }
+
+ /** @param array $directives */
+ private function boolDirective(array $directives, string $key): bool
+ {
+ $value = $directives[$key] ?? 'false';
+
+ return \in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
+ }
+}
diff --git a/src/Security/Encryptor.php b/src/Security/Encryptor.php
new file mode 100644
index 0000000..0f9426c
--- /dev/null
+++ b/src/Security/Encryptor.php
@@ -0,0 +1,113 @@
+`
+ *
+ * Uses PHP's ext-openssl (bundled in standard PHP distributions).
+ * No external tools or Node.js CLI required — unlike dotenvx.
+ *
+ * ```php
+ * $encryptor = new Encryptor($privateKey);
+ * $encrypted = $encryptor->encrypt('my-secret');
+ * // → "encrypted:base64..."
+ *
+ * $decrypted = $encryptor->decrypt($encrypted);
+ * // → "my-secret"
+ * ```
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.3.0
+ */
+final class Encryptor
+{
+ private const string CIPHER = 'aes-256-gcm';
+ private const int NONCE_LENGTH = 12;
+ private const int TAG_LENGTH = 16;
+ private const string PREFIX = 'encrypted:';
+
+ private readonly string $key;
+
+ /**
+ * @param string $key 64-char hex string (256-bit key) or 32-byte raw binary.
+ */
+ public function __construct(string $key)
+ {
+ $this->key = \strlen($key) === 64 && ctype_xdigit($key)
+ ? (string) hex2bin($key)
+ : $key;
+
+ if (\strlen($this->key) !== 32) {
+ throw new \InvalidArgumentException(
+ 'Encryption key must be 32 bytes (256-bit) or 64-char hex string.',
+ );
+ }
+ }
+
+ public function encrypt(string $plaintext): string
+ {
+ $nonce = random_bytes(self::NONCE_LENGTH);
+ $tag = '';
+
+ $ciphertext = openssl_encrypt(
+ $plaintext,
+ self::CIPHER,
+ $this->key,
+ OPENSSL_RAW_DATA,
+ $nonce,
+ $tag,
+ '',
+ self::TAG_LENGTH,
+ );
+
+ if ($ciphertext === false) {
+ throw new \RuntimeException('Encryption failed: ' . (string) openssl_error_string());
+ }
+
+ return self::PREFIX . base64_encode($nonce . $ciphertext . $tag);
+ }
+
+ public function decrypt(string $payload): string
+ {
+ if (! self::isEncrypted($payload)) {
+ return $payload;
+ }
+
+ $raw = base64_decode(substr($payload, \strlen(self::PREFIX)), true);
+
+ if ($raw === false || \strlen($raw) < self::NONCE_LENGTH + self::TAG_LENGTH) {
+ throw new \RuntimeException('Invalid encrypted payload: malformed base64 or too short.');
+ }
+
+ $nonce = substr($raw, 0, self::NONCE_LENGTH);
+ $tag = substr($raw, -self::TAG_LENGTH);
+ $ciphertext = substr($raw, self::NONCE_LENGTH, -self::TAG_LENGTH);
+
+ $plaintext = openssl_decrypt(
+ $ciphertext,
+ self::CIPHER,
+ $this->key,
+ OPENSSL_RAW_DATA,
+ $nonce,
+ $tag,
+ );
+
+ if ($plaintext === false) {
+ throw new \RuntimeException(
+ 'Decryption failed — wrong key or corrupted payload.',
+ );
+ }
+
+ return $plaintext;
+ }
+
+ public static function isEncrypted(string $value): bool
+ {
+ return str_starts_with($value, self::PREFIX);
+ }
+}
diff --git a/src/Security/KeyPair.php b/src/Security/KeyPair.php
new file mode 100644
index 0000000..acd2918
--- /dev/null
+++ b/src/Security/KeyPair.php
@@ -0,0 +1,56 @@
+privateKey = $privateKey;
+ $this->publicId = $publicId;
+ }
+
+ /**
+ * Generates a new random key pair.
+ *
+ * The private key is the actual 256-bit AES key (hex-encoded).
+ * The public ID is an 8-char identifier for referencing this key
+ * in multi-environment setups (derived from the key hash).
+ */
+ public static function generate(): self
+ {
+ $rawKey = random_bytes(32);
+ $privateKey = bin2hex($rawKey);
+ $publicId = substr(hash('sha256', $rawKey), 0, 8);
+
+ return new self($privateKey, $publicId);
+ }
+
+ /**
+ * Reconstitutes a KeyPair from an existing private key.
+ */
+ public static function fromPrivateKey(string $privateKey): self
+ {
+ if (\strlen($privateKey) !== 64 || ! ctype_xdigit($privateKey)) {
+ throw new \InvalidArgumentException(
+ 'Private key must be a 64-character hex string (256-bit).',
+ );
+ }
+
+ $publicId = substr(hash('sha256', (string) hex2bin($privateKey)), 0, 8);
+
+ return new self($privateKey, $publicId);
+ }
+}
diff --git a/src/Type/Caster/ArrayCaster.php b/src/Type/Caster/ArrayCaster.php
index 601f9c0..48650f2 100644
--- a/src/Type/Caster/ArrayCaster.php
+++ b/src/Type/Caster/ArrayCaster.php
@@ -4,22 +4,22 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeCaster;
-class ArrayCaster implements TypeCaster
+/**
+ * Decodes a JSON array string into a PHP list array.
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class ArrayCaster implements TypeCaster
{
- public function cast(mixed $value): array
+ /** @return list */
+ #[\Override]
+ public function cast(string $value): array
{
- if (is_string($value)) {
- $trimmed = trim($value, "[] \t\n\r\0\x0B");
- if ('' === $trimmed) {
- return [];
- }
- $items = explode(',', $trimmed);
+ $decoded = json_decode(trim($value), true, 512, JSON_THROW_ON_ERROR);
- return array_map(fn ($item) => trim($item, " \t\n\r\0\x0B\"'"), $items);
- }
-
- return (array) $value;
+ return \is_array($decoded) ? array_values($decoded) : [];
}
}
diff --git a/src/Type/Caster/BooleanCaster.php b/src/Type/Caster/BooleanCaster.php
index c462ba5..34e7c23 100644
--- a/src/Type/Caster/BooleanCaster.php
+++ b/src/Type/Caster/BooleanCaster.php
@@ -4,23 +4,19 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeCaster;
-class BooleanCaster implements TypeCaster
+/**
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class BooleanCaster implements TypeCaster
{
- public function canCast(mixed $value): bool
- {
- $value = strtolower($value);
-
- return in_array($value, [
- 'true', 'false', '1', '0', 'yes', 'no', 'on', 'off',
- ], true);
- }
+ private const array TRUE_VALUES = ['true', 'yes', 'on', '(true)'];
- public function cast(mixed $value): bool
+ #[\Override]
+ public function cast(string $value): bool
{
- $value = strtolower($value);
-
- return in_array($value, ['true', '1', 'yes', 'on'], true);
+ return \in_array(strtolower($value), self::TRUE_VALUES, true);
}
}
diff --git a/src/Type/Caster/DotenvTypeCasterRegistry.php b/src/Type/Caster/DotenvTypeCasterRegistry.php
deleted file mode 100644
index 4db2ca4..0000000
--- a/src/Type/Caster/DotenvTypeCasterRegistry.php
+++ /dev/null
@@ -1,46 +0,0 @@
-casters = new ArrayList();
- $this->registerDefaultCasters();
- }
-
- public function register(string $type, TypeCaster $caster): void
- {
- $this->casters->set($type, $caster);
- }
-
- public function cast(string $type, mixed $value): mixed
- {
- $caster = $this->casters->get($type);
- if ($caster instanceof TypeCaster) {
- return $caster->cast($value);
- }
-
- return $value; // Fallback: return original value if no caster found
- }
-
- private function registerDefaultCasters(): void
- {
- $this->register('array', new ArrayCaster());
- $this->register('json', new JsonCaster());
- $this->register('null', new NullCaster());
- $this->register('boolean', new BooleanCaster());
- $this->register('integer', new IntegerCaster());
- $this->register('float', new FloatCaster());
- $this->register('string', new StringCaster());
- }
-}
diff --git a/src/Type/Caster/FloatCaster.php b/src/Type/Caster/FloatCaster.php
index de2f4f1..9d70d0d 100644
--- a/src/Type/Caster/FloatCaster.php
+++ b/src/Type/Caster/FloatCaster.php
@@ -4,16 +4,16 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeCaster;
-class FloatCaster implements TypeCaster
+/**
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class FloatCaster implements TypeCaster
{
- public function canCast(mixed $value): bool
- {
- return is_numeric($value) && false !== strpos((string) $value, '.');
- }
-
- public function cast(mixed $value): float
+ #[\Override]
+ public function cast(string $value): float
{
return (float) $value;
}
diff --git a/src/Type/Caster/IntegerCaster.php b/src/Type/Caster/IntegerCaster.php
index 30eb0b4..36b9111 100644
--- a/src/Type/Caster/IntegerCaster.php
+++ b/src/Type/Caster/IntegerCaster.php
@@ -4,16 +4,16 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeCaster;
-class IntegerCaster implements TypeCaster
+/**
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class IntegerCaster implements TypeCaster
{
- public function canCast(mixed $value): bool
- {
- return is_numeric($value) && (string) (int) $value === (string) $value;
- }
-
- public function cast(mixed $value): int
+ #[\Override]
+ public function cast(string $value): int
{
return (int) $value;
}
diff --git a/src/Type/Caster/JsonCaster.php b/src/Type/Caster/JsonCaster.php
index e87acb2..f0b4590 100644
--- a/src/Type/Caster/JsonCaster.php
+++ b/src/Type/Caster/JsonCaster.php
@@ -4,38 +4,26 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
-
-class JsonCaster implements TypeCaster
+use KaririCode\Dotenv\Contract\TypeCaster;
+
+/**
+ * Decodes a JSON object string into a PHP associative array.
+ *
+ * Throws JsonException on malformed input rather than returning false/null,
+ * ensuring fail-fast behavior in production.
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class JsonCaster implements TypeCaster
{
- /**
- * @throws \JsonException
- */
- public function cast(mixed $value): mixed
- {
- if (!is_string($value)) {
- return $value;
- }
-
- $trimmedValue = $this->removeSurroundingQuotes($value);
-
- return $this->decodeJson($trimmedValue);
- }
-
- private function removeSurroundingQuotes(string $value): string
+ /** @return array */
+ #[\Override]
+ public function cast(string $value): array
{
- return trim($value, '"\'');
- }
+ $decoded = json_decode(trim($value), true, 512, JSON_THROW_ON_ERROR);
- /**
- * @throws \JsonException
- */
- private function decodeJson(string $json): mixed
- {
- try {
- return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- return $json;
- }
+ /** @var array $decoded */
+ return $decoded;
}
}
diff --git a/src/Type/Caster/NullCaster.php b/src/Type/Caster/NullCaster.php
index 2089e2d..21838fc 100644
--- a/src/Type/Caster/NullCaster.php
+++ b/src/Type/Caster/NullCaster.php
@@ -4,16 +4,16 @@
namespace KaririCode\Dotenv\Type\Caster;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
+use KaririCode\Dotenv\Contract\TypeCaster;
-class NullCaster implements TypeCaster
+/**
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class NullCaster implements TypeCaster
{
- public function canCast(mixed $value): bool
- {
- return 'null' === $value || '' === $value;
- }
-
- public function cast(mixed $value): ?string
+ #[\Override]
+ public function cast(string $value): null
{
return null;
}
diff --git a/src/Type/Caster/StringCaster.php b/src/Type/Caster/StringCaster.php
deleted file mode 100644
index 2080664..0000000
--- a/src/Type/Caster/StringCaster.php
+++ /dev/null
@@ -1,24 +0,0 @@
-isStringInput($value)) {
- return null;
- }
+ return 150;
+ }
- $cleanValue = $this->removeWhitespace($value);
+ #[\Override]
+ public function detect(string $value): ?ValueType
+ {
+ $trimmed = trim($value);
- if ($this->isArrayFormat($cleanValue)) {
- return 'array';
+ if (! str_starts_with($trimmed, '[') || ! str_ends_with($trimmed, ']')) {
+ return null;
}
- return null;
- }
+ $decoded = json_decode($trimmed, true);
- private function isArrayFormat(string $value): bool
- {
- return $this->hasDelimiters($value, '[', ']');
+ return json_last_error() === JSON_ERROR_NONE && \is_array($decoded) ? ValueType::Array : null;
}
}
diff --git a/src/Type/Detector/BooleanDetector.php b/src/Type/Detector/BooleanDetector.php
index 1b548f4..7e5bf68 100644
--- a/src/Type/Detector/BooleanDetector.php
+++ b/src/Type/Detector/BooleanDetector.php
@@ -4,22 +4,39 @@
namespace KaririCode\Dotenv\Type\Detector;
-class BooleanDetector extends AbstractTypeDetector
+use KaririCode\Dotenv\Contract\TypeDetector;
+use KaririCode\Dotenv\Enum\ValueType;
+
+/**
+ * Detects boolean literals: true/false, yes/no, on/off (case-insensitive).
+ *
+ * Uses normalized lowercase comparison to match the same values the
+ * BooleanCaster handles, avoiding asymmetry between detection and casting.
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class BooleanDetector implements TypeDetector
{
- public const PRIORITY = 80;
+ private const array BOOLEAN_LITERALS = [
+ 'true', 'false', 'yes', 'no', 'on', 'off',
+ ];
+
+ private const array BOOLEAN_PARENTHESIZED = ['(true)', '(false)'];
- public function detect(mixed $value): ?string
+ #[\Override]
+ public function priority(): int
{
- if (is_bool($value)) {
- return 'boolean';
- }
- if (is_string($value)) {
- $lower = strtolower($value);
- if (in_array($lower, ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'], true)) {
- return 'boolean';
- }
+ return 190;
+ }
+
+ #[\Override]
+ public function detect(string $value): ?ValueType
+ {
+ if (\in_array(strtolower($value), self::BOOLEAN_LITERALS, true)) {
+ return ValueType::Boolean;
}
- return null;
+ return \in_array($value, self::BOOLEAN_PARENTHESIZED, true) ? ValueType::Boolean : null;
}
}
diff --git a/src/Type/Detector/DotenvTypeDetectorRegistry.php b/src/Type/Detector/DotenvTypeDetectorRegistry.php
deleted file mode 100644
index ca50971..0000000
--- a/src/Type/Detector/DotenvTypeDetectorRegistry.php
+++ /dev/null
@@ -1,61 +0,0 @@
-registerDefaultDetectors();
- }
-
- public function registerDetector(TypeDetector $detector): void
- {
- $this->detectors->add($detector);
- $this->sortDetectors();
- }
-
- public function detectType(mixed $value): string
- {
- foreach ($this->detectors->getItems() as $detector) {
- if ($type = $detector->detect($value)) {
- return $type;
- }
- }
-
- return 'string'; // Fallback
- }
-
- private function registerDefaultDetectors(): void
- {
- $defaultDetectors = [
- new ArrayDetector(),
- new JsonDetector(),
- new NullDetector(),
- new BooleanDetector(),
- new NumericDetector(),
- new StringDetector(),
- ];
-
- foreach ($defaultDetectors as $detector) {
- $this->registerDetector($detector);
- }
- }
-
- private function sortDetectors(): void
- {
- $detectors = $this->detectors->getItems();
- usort($detectors, fn (TypeDetector $a, TypeDetector $b) => $b->getPriority() - $a->getPriority());
- $this->detectors->clear();
- foreach ($detectors as $detector) {
- $this->detectors->add($detector);
- }
- }
-}
diff --git a/src/Type/Detector/FloatDetector.php b/src/Type/Detector/FloatDetector.php
new file mode 100644
index 0000000..a26a2d1
--- /dev/null
+++ b/src/Type/Detector/FloatDetector.php
@@ -0,0 +1,40 @@
+isStringInput($value)) {
- return null;
- }
-
- $cleanValue = $this->removeWhitespace($value);
-
- if ($this->isJsonObject($cleanValue)) {
- return 'json';
- }
-
- if ($this->isJsonArrayOfObjects($cleanValue)) {
- return 'json';
- }
-
- return null;
- }
-
- private function isJsonObject(string $value): bool
+ #[\Override]
+ public function priority(): int
{
- return $this->isObjectFormat($value) && $this->isValidJson($value);
+ return 160;
}
- private function isJsonArrayOfObjects(string $value): bool
+ #[\Override]
+ public function detect(string $value): ?ValueType
{
- if (!$this->isArrayFormat($value)) {
- return false;
- }
-
- $decoded = json_decode($value, true);
- if (JSON_ERROR_NONE !== json_last_error() || !is_array($decoded)) {
- return false;
- }
-
- if (empty($decoded)) {
- return true;
- }
+ $trimmed = trim($value);
- foreach ($decoded as $item) {
- if (!is_array($item) || $this->isSequentialArray($item)) {
- return false;
- }
+ if (! str_starts_with($trimmed, '{') || ! str_ends_with($trimmed, '}')) {
+ return null;
}
- return true;
- }
-
- private function isObjectFormat(string $value): bool
- {
- return $this->hasDelimiters($value, '{', '}');
- }
-
- private function isArrayFormat(string $value): bool
- {
- return $this->hasDelimiters($value, '[', ']');
- }
-
- private function isValidJson(string $value): bool
- {
- json_decode($value);
-
- return JSON_ERROR_NONE === json_last_error();
- }
+ json_decode($trimmed);
- private function isSequentialArray(array $arr): bool
- {
- return array_keys($arr) === range(0, count($arr) - 1);
+ return json_last_error() === JSON_ERROR_NONE ? ValueType::Json : null;
}
}
diff --git a/src/Type/Detector/NullDetector.php b/src/Type/Detector/NullDetector.php
index b1a3d53..4d939e9 100644
--- a/src/Type/Detector/NullDetector.php
+++ b/src/Type/Detector/NullDetector.php
@@ -4,16 +4,32 @@
namespace KaririCode\Dotenv\Type\Detector;
-class NullDetector extends AbstractTypeDetector
+use KaririCode\Dotenv\Contract\TypeDetector;
+use KaririCode\Dotenv\Enum\ValueType;
+
+/**
+ * Detects null literals: "null", "NULL", "(null)".
+ *
+ * Empty string ('') is intentionally excluded — `FOO=` must yield an
+ * empty string, not null. This matches POSIX behavior and prevents
+ * data loss when distinguishing between `FOO=` and `FOO=null`.
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.0.0 ARFA 1.3
+ */
+final readonly class NullDetector implements TypeDetector
{
- public const PRIORITY = 95;
+ private const array NULL_LITERALS = ['null', 'NULL', '(null)'];
- public function detect(mixed $value): ?string
+ #[\Override]
+ public function priority(): int
{
- if (null === $value || 'null' === strtolower($value) || '' === $value) {
- return 'null';
- }
+ return 200;
+ }
- return null;
+ #[\Override]
+ public function detect(string $value): ?ValueType
+ {
+ return \in_array($value, self::NULL_LITERALS, true) ? ValueType::Null : null;
}
}
diff --git a/src/Type/Detector/NumericDetector.php b/src/Type/Detector/NumericDetector.php
deleted file mode 100644
index f4c03c1..0000000
--- a/src/Type/Detector/NumericDetector.php
+++ /dev/null
@@ -1,19 +0,0 @@
-removeWhitespace($element))) {
- return false;
- }
- }
-
- return true;
- }
-}
diff --git a/src/Type/Detector/Trait/StringValidatorTrait.php b/src/Type/Detector/Trait/StringValidatorTrait.php
deleted file mode 100644
index 246c3e2..0000000
--- a/src/Type/Detector/Trait/StringValidatorTrait.php
+++ /dev/null
@@ -1,23 +0,0 @@
-detectorRegistry->registerDetector($detector);
- }
-
- public function registerCaster(string $type, TypeCaster $caster): void
- {
- $this->casterRegistry->register($type, $caster);
- }
-
- public function processValue(mixed $value): mixed
- {
- $detectedType = $this->detectorRegistry->detectType($value);
-
- return $this->casterRegistry->cast($detectedType, $value);
- }
-}
diff --git a/src/Type/TypeSystem.php b/src/Type/TypeSystem.php
new file mode 100644
index 0000000..4fb07a3
--- /dev/null
+++ b/src/Type/TypeSystem.php
@@ -0,0 +1,130 @@
+ Sorted by priority descending. */
+ private array $detectors = [];
+
+ private bool $sorted = false;
+
+ /** @var array Keyed by ValueType->name. */
+ private array $casters = [];
+
+ public function __construct()
+ {
+ $this->registerDefaults();
+ }
+
+ public function addDetector(TypeDetector $detector): void
+ {
+ $this->detectors[] = $detector;
+ $this->sorted = false;
+ }
+
+ public function addCaster(ValueType $type, TypeCaster $caster): void
+ {
+ $this->casters[$type->name] = $caster;
+ }
+
+ public function detect(string $value): ValueType
+ {
+ $this->ensureSorted();
+
+ foreach ($this->detectors as $detector) {
+ $detected = $detector->detect($value);
+ if ($detected !== null) {
+ return $detected;
+ }
+ }
+
+ return ValueType::String;
+ }
+
+ public function cast(string $value, ValueType $type): mixed
+ {
+ if ($type === ValueType::String) {
+ return $value;
+ }
+
+ $caster = $this->casters[$type->name] ?? null;
+
+ return $caster !== null ? $caster->cast($value) : $value;
+ }
+
+ public function resolve(string $value): mixed
+ {
+ $type = $this->detect($value);
+
+ return $this->cast($value, $type);
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────
+
+ private function registerDefaults(): void
+ {
+ $this->detectors = [
+ new NullDetector(),
+ new BooleanDetector(),
+ new IntegerDetector(),
+ new FloatDetector(),
+ new JsonDetector(),
+ new ArrayDetector(),
+ ];
+ $this->sorted = false;
+
+ $this->casters = [
+ ValueType::Null->name => new NullCaster(),
+ ValueType::Boolean->name => new BooleanCaster(),
+ ValueType::Integer->name => new IntegerCaster(),
+ ValueType::Float->name => new FloatCaster(),
+ ValueType::Json->name => new JsonCaster(),
+ ValueType::Array->name => new ArrayCaster(),
+ ];
+ }
+
+ private function ensureSorted(): void
+ {
+ if ($this->sorted) {
+ return;
+ }
+
+ usort($this->detectors, static fn (TypeDetector $a, TypeDetector $b): int => $b->priority() <=> $a->priority());
+ $this->sorted = true;
+ }
+}
diff --git a/src/Validation/EnvironmentValidator.php b/src/Validation/EnvironmentValidator.php
new file mode 100644
index 0000000..d84c5bd
--- /dev/null
+++ b/src/Validation/EnvironmentValidator.php
@@ -0,0 +1,256 @@
+validate()
+ * ->required('DB_HOST', 'DB_PORT')
+ * ->notEmpty('DB_HOST')
+ * ->isInteger('DB_PORT')->between(1, 65535)
+ * ->isBoolean('APP_DEBUG')
+ * ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ * ->ifPresent('REDIS_HOST')->notEmpty()
+ * ->assert();
+ * ```
+ *
+ * @package KaririCode\Dotenv
+ * @since 4.1.0
+ */
+final class EnvironmentValidator
+{
+ /** @var array> Rules keyed by variable name. */
+ private array $rules = [];
+
+ /** @var list Variables that must exist. */
+ private array $requiredNames = [];
+
+ /** @var list Current target variable names for fluent chaining. */
+ private array $currentTargets = [];
+
+ /** @var bool When true, rules only apply if the variable exists. */
+ private bool $conditionalMode = false;
+
+ /**
+ * @param \Closure(string): ?string $valueResolver Returns raw value or null if not set.
+ */
+ public function __construct(
+ private readonly \Closure $valueResolver,
+ ) {
+ }
+
+ // ── Targeting ─────────────────────────────────────────────────────
+
+ /**
+ * Declares variables that must exist (non-null). Does not apply rules to them
+ * unless followed by chained rule methods.
+ */
+ public function required(string ...$names): self
+ {
+ $this->requiredNames = array_values(array_merge($this->requiredNames, $names));
+ $this->currentTargets = array_values($names);
+ $this->conditionalMode = false;
+
+ return $this;
+ }
+
+ /**
+ * Targets variables for rule application. If a variable is absent,
+ * validation for it is silently skipped.
+ */
+ public function ifPresent(string ...$names): self
+ {
+ $this->currentTargets = array_values($names);
+ $this->conditionalMode = true;
+
+ return $this;
+ }
+
+ // ── Built-in Rules ────────────────────────────────────────────────
+
+ public function notEmpty(string ...$names): self
+ {
+ return $this->applyRule(new NotEmptyRule(), array_values($names));
+ }
+
+ public function isInteger(string ...$names): self
+ {
+ return $this->applyRule(new IsIntegerRule(), array_values($names));
+ }
+
+ public function isBoolean(string ...$names): self
+ {
+ return $this->applyRule(new IsBooleanRule(), array_values($names));
+ }
+
+ public function isNumeric(string ...$names): self
+ {
+ return $this->applyRule(new IsNumericRule(), array_values($names));
+ }
+
+ /**
+ * Applies a numeric range constraint. Requires prior isInteger() or isNumeric().
+ * Always targets the current chain (set by required/ifPresent/isInteger/etc).
+ */
+ public function between(int|float $min, int|float $max): self
+ {
+ return $this->applyRule(new BetweenRule($min, $max), []);
+ }
+
+ /**
+ * @param list $allowed Accepted values for this variable.
+ */
+ public function allowedValues(string $name, array $allowed): self
+ {
+ $this->currentTargets = [$name];
+
+ return $this->applyRule(new AllowedValuesRule($allowed), []);
+ }
+
+ public function matchesRegex(string $name, string $pattern): self
+ {
+ $this->currentTargets = [$name];
+
+ return $this->applyRule(new MatchesRegexRule($pattern), []);
+ }
+
+ public function url(string ...$names): self
+ {
+ return $this->applyRule(new UrlRule(), array_values($names));
+ }
+
+ public function email(string ...$names): self
+ {
+ return $this->applyRule(new EmailRule(), array_values($names));
+ }
+
+ /**
+ * @param \Closure(string): bool $callback
+ */
+ public function custom(string $name, \Closure $callback, string $message = ''): self
+ {
+ $this->currentTargets = [$name];
+ $msg = $message !== '' ? $message : '{name} failed custom validation.';
+
+ return $this->applyRule(new CustomRule($callback, $msg), []);
+ }
+
+ /**
+ * Adds an arbitrary ValidationRule implementation to the current targets.
+ */
+ public function rule(ValidationRule $rule, string ...$names): self
+ {
+ return $this->applyRule($rule, array_values($names));
+ }
+
+ // ── Execution ─────────────────────────────────────────────────────
+
+ /**
+ * Runs all collected rules and throws with ALL failures if any exist.
+ *
+ * @throws ValidationException Containing every failure message.
+ */
+ public function assert(): void
+ {
+ $errors = [];
+
+ // Check required presence first
+ $missingRequired = [];
+ foreach (array_unique($this->requiredNames) as $name) {
+ $value = ($this->valueResolver)($name);
+ if ($value === null) {
+ $missingRequired[] = $name;
+ }
+ }
+
+ if ($missingRequired !== []) {
+ foreach ($missingRequired as $name) {
+ $errors[] = "{$name} is required but not defined.";
+ }
+ }
+
+ // Run rules per variable
+ foreach ($this->rules as $name => $ruleList) {
+ $value = ($this->valueResolver)($name);
+
+ // Skip absent variables in conditional mode
+ if ($value === null) {
+ if ($this->isConditional($name)) {
+ continue;
+ }
+
+ // Already reported as missing-required above, don't run rules
+ if (\in_array($name, $this->requiredNames, true)) {
+ continue;
+ }
+
+ // Not required, not conditional — skip silently
+ continue;
+ }
+
+ foreach ($ruleList as $rule) {
+ if (! $rule->passes($value)) {
+ $errors[] = str_replace('{name}', $name, $rule->message());
+ }
+ }
+ }
+
+ if ($errors !== []) {
+ throw ValidationException::batchErrors($errors);
+ }
+ }
+
+ // ── Internal ──────────────────────────────────────────────────────
+
+ /** @var array Variables registered via ifPresent(). */
+ private array $conditionalNames = [];
+
+ private function isConditional(string $name): bool
+ {
+ return isset($this->conditionalNames[$name]);
+ }
+
+ /**
+ * @param list $explicitNames Override current targets when non-empty.
+ */
+ private function applyRule(ValidationRule $rule, array $explicitNames): self
+ {
+ $targets = $explicitNames !== [] ? $explicitNames : $this->currentTargets;
+
+ if ($explicitNames !== []) {
+ $this->currentTargets = $explicitNames;
+ }
+
+ if ($this->conditionalMode) {
+ foreach ($targets as $name) {
+ $this->conditionalNames[$name] = true;
+ }
+ }
+
+ foreach ($targets as $name) {
+ $this->rules[$name][] = $rule;
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Validation/Rule/AllowedValuesRule.php b/src/Validation/Rule/AllowedValuesRule.php
new file mode 100644
index 0000000..1cd8ce0
--- /dev/null
+++ b/src/Validation/Rule/AllowedValuesRule.php
@@ -0,0 +1,30 @@
+ $allowed */
+ public function __construct(
+ private array $allowed,
+ ) {
+ }
+
+ #[\Override]
+ public function passes(string $value): bool
+ {
+ return \in_array($value, $this->allowed, true);
+ }
+
+ #[\Override]
+ public function message(): string
+ {
+ $list = implode(', ', $this->allowed);
+
+ return "{name} must be one of: {$list}.";
+ }
+}
diff --git a/src/Validation/Rule/BetweenRule.php b/src/Validation/Rule/BetweenRule.php
new file mode 100644
index 0000000..e09c1e6
--- /dev/null
+++ b/src/Validation/Rule/BetweenRule.php
@@ -0,0 +1,34 @@
+= $this->min && $numeric <= $this->max;
+ }
+
+ #[\Override]
+ public function message(): string
+ {
+ return "{name} must be between {$this->min} and {$this->max}.";
+ }
+}
diff --git a/src/Validation/Rule/CustomRule.php b/src/Validation/Rule/CustomRule.php
new file mode 100644
index 0000000..d181a4d
--- /dev/null
+++ b/src/Validation/Rule/CustomRule.php
@@ -0,0 +1,29 @@
+callback)($value);
+ }
+
+ #[\Override]
+ public function message(): string
+ {
+ return $this->failureMessage;
+ }
+}
diff --git a/src/Validation/Rule/EmailRule.php b/src/Validation/Rule/EmailRule.php
new file mode 100644
index 0000000..99018ed
--- /dev/null
+++ b/src/Validation/Rule/EmailRule.php
@@ -0,0 +1,22 @@
+pattern, $value) === 1;
+ }
+
+ #[\Override]
+ public function message(): string
+ {
+ return "{name} must match pattern {$this->pattern}.";
+ }
+}
diff --git a/src/Validation/Rule/NotEmptyRule.php b/src/Validation/Rule/NotEmptyRule.php
new file mode 100644
index 0000000..76e8ab6
--- /dev/null
+++ b/src/Validation/Rule/NotEmptyRule.php
@@ -0,0 +1,22 @@
+ $allowList Glob patterns for allowed variable names (empty = all).
+ * @param list $denyList Glob patterns for denied variable names.
+ * @param string|null $environmentName Environment name for cascade loading (e.g., "production").
+ */
+ public function __construct(
+ public LoadMode $loadMode = LoadMode::Immutable,
+ public bool $strictNames = false,
+ public bool $typeCasting = true,
+ public bool $populateEnv = true,
+ public bool $populateServer = true,
+ public bool $usePutenv = false,
+ public ?string $encryptionKey = null,
+ public ?string $cachePath = null,
+ public array $allowList = [],
+ public array $denyList = [],
+ public ?string $environmentName = null,
+ ) {
+ }
+
+ public function withLoadMode(LoadMode $loadMode): self
+ {
+ return new self(
+ loadMode: $loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ public function withStrictNames(bool $strict): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $strict,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ public function withTypeCasting(bool $enabled): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $enabled,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ public function withEncryptionKey(?string $key): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $key,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ public function withCachePath(?string $path): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $path,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ /**
+ * Return a new instance with the given allow-list glob patterns.
+ *
+ * @param list $patterns Glob patterns for allowed variable names.
+ * @since 4.0.0
+ */
+ public function withAllowList(array $patterns): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $patterns,
+ denyList: $this->denyList,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ /**
+ * Return a new instance with the given deny-list glob patterns.
+ *
+ * @param list $patterns Glob patterns for denied variable names.
+ * @since 4.0.0
+ */
+ public function withDenyList(array $patterns): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $patterns,
+ environmentName: $this->environmentName,
+ );
+ }
+
+ public function withEnvironmentName(?string $name): self
+ {
+ return new self(
+ loadMode: $this->loadMode,
+ strictNames: $this->strictNames,
+ typeCasting: $this->typeCasting,
+ populateEnv: $this->populateEnv,
+ populateServer: $this->populateServer,
+ usePutenv: $this->usePutenv,
+ encryptionKey: $this->encryptionKey,
+ cachePath: $this->cachePath,
+ allowList: $this->allowList,
+ denyList: $this->denyList,
+ environmentName: $name,
+ );
+ }
+}
diff --git a/src/ValueObject/EnvironmentVariable.php b/src/ValueObject/EnvironmentVariable.php
new file mode 100644
index 0000000..d0e7e95
--- /dev/null
+++ b/src/ValueObject/EnvironmentVariable.php
@@ -0,0 +1,37 @@
+resolve($value);
+}
diff --git a/src/functions.php b/src/functions.php
deleted file mode 100644
index de12ebb..0000000
--- a/src/functions.php
+++ /dev/null
@@ -1,18 +0,0 @@
-parser = $this->createMock(Parser::class);
- $this->loader = $this->createMock(Loader::class);
- $this->typeSystem = $this->createMock(TypeSystem::class);
- $this->dotenv = new Dotenv($this->parser, $this->loader, $this->typeSystem);
- }
-
- public function testLoad(): void
- {
- $envContent = "KEY1=value1\nKEY2=value2";
- $parsedContent = ['KEY1' => 'value1', 'KEY2' => 'value2'];
-
- $this->loader->expects($this->once())
- ->method('load')
- ->willReturn($envContent);
-
- $this->parser->expects($this->once())
- ->method('parse')
- ->with($envContent)
- ->willReturn($parsedContent);
-
- $this->typeSystem->expects($this->exactly(2))
- ->method('processValue')
- ->willReturnArgument(0);
-
- $this->dotenv->load();
-
- $this->assertSame('value1', $_ENV['KEY1']);
- $this->assertSame('value2', $_ENV['KEY2']);
- $this->assertSame('value1', $_SERVER['KEY1']);
- $this->assertSame('value2', $_SERVER['KEY2']);
- }
-
- public function testAddTypeDetector(): void
- {
- $detector = $this->createMock(TypeDetector::class);
-
- $this->typeSystem->expects($this->once())
- ->method('registerDetector')
- ->with($detector);
-
- $result = $this->dotenv->addTypeDetector($detector);
-
- $this->assertSame($this->dotenv, $result);
- }
-
- public function testAddTypeCaster(): void
- {
- $type = 'custom_type';
- $caster = $this->createMock(TypeCaster::class);
-
- $this->typeSystem->expects($this->once())
- ->method('registerCaster')
- ->with($type, $caster);
-
- $result = $this->dotenv->addTypeCaster($type, $caster);
-
- $this->assertSame($this->dotenv, $result);
- }
-
- public function testLoadWithTypeProcessing(): void
- {
- $envContent = "KEY1=true\nKEY2=123";
- $parsedContent = ['KEY1' => 'true', 'KEY2' => '123'];
-
- $this->loader->expects($this->once())
- ->method('load')
- ->willReturn($envContent);
-
- $this->parser->expects($this->once())
- ->method('parse')
- ->with($envContent)
- ->willReturn($parsedContent);
-
- $this->typeSystem->expects($this->exactly(2))
- ->method('processValue')
- ->willReturnMap([
- ['true', true],
- ['123', 123],
- ]);
-
- $this->dotenv->load();
-
- $this->assertSame(true, $_ENV['KEY1']);
- $this->assertSame(123, $_ENV['KEY2']);
- $this->assertSame(true, $_SERVER['KEY1']);
- $this->assertSame(123, $_SERVER['KEY2']);
- }
-
- public function testLoadWithInvalidValue(): void
- {
- $envContent = 'INVALID_KEY=invalid_value';
- $parsedContent = ['INVALID_KEY' => 'invalid_value'];
-
- $this->loader->expects($this->once())
- ->method('load')
- ->willReturn($envContent);
-
- $this->parser->expects($this->once())
- ->method('parse')
- ->with($envContent)
- ->willReturn($parsedContent);
-
- $this->typeSystem->expects($this->once())
- ->method('processValue')
- ->willThrowException(new InvalidValueException('Invalid value'));
-
- $this->expectException(InvalidValueException::class);
- $this->expectExceptionMessage('Invalid value');
-
- $this->dotenv->load();
- }
-
- public function testLoadWithEmptyContent(): void
- {
- $envContent = '';
- $parsedContent = [];
-
- $this->loader->expects($this->once())
- ->method('load')
- ->willReturn($envContent);
-
- $this->parser->expects($this->once())
- ->method('parse')
- ->with($envContent)
- ->willReturn($parsedContent);
-
- $this->typeSystem->expects($this->never())
- ->method('processValue');
-
- $initialEnv = $_ENV;
- $initialServer = $_SERVER;
-
- $this->dotenv->load();
-
- $this->assertSame($initialEnv, $_ENV);
- $this->assertSame($initialServer, $_SERVER);
- }
-
- protected function tearDown(): void
- {
- parent::tearDown();
- $_ENV = [];
- $_SERVER = [];
- }
-}
diff --git a/tests/Integration/DotenvIntegrationTest.php b/tests/Integration/DotenvIntegrationTest.php
index 072b8ea..d25b95c 100644
--- a/tests/Integration/DotenvIntegrationTest.php
+++ b/tests/Integration/DotenvIntegrationTest.php
@@ -4,98 +4,638 @@
namespace KaririCode\Dotenv\Tests\Integration;
-use KaririCode\Dotenv\Contract\Type\TypeCaster;
-use KaririCode\Dotenv\Contract\Type\TypeDetector;
-use KaririCode\Dotenv\DotenvFactory;
+use KaririCode\Dotenv\Dotenv;
+use KaririCode\Dotenv\Enum\LoadMode;
+
+use function KaririCode\Dotenv\env;
+
+use KaririCode\Dotenv\Exception\FileNotFoundException;
+use KaririCode\Dotenv\Exception\ValidationException;
+use KaririCode\Dotenv\Processor\CsvToArrayProcessor;
+use KaririCode\Dotenv\Processor\UrlNormalizerProcessor;
+use KaririCode\Dotenv\Security\Encryptor;
+use KaririCode\Dotenv\Security\KeyPair;
+use KaririCode\Dotenv\ValueObject\DotenvConfiguration;
use PHPUnit\Framework\TestCase;
final class DotenvIntegrationTest extends TestCase
{
- private string $envFile;
+ private string $fixturesDir;
+
+ /** @var array $_ENV snapshot before each test. */
+ private array $envSnapshot;
+
+ /** @var array $_SERVER snapshot before each test. */
+ private array $serverSnapshot;
protected function setUp(): void
{
- parent::setUp();
- $this->envFile = sys_get_temp_dir() . '/test_' . uniqid() . '.env';
+ $this->fixturesDir = sys_get_temp_dir() . '/kariricode-dotenv-test-' . uniqid();
+ mkdir($this->fixturesDir, 0o755, true);
+
+ // Snapshot environment to restore in tearDown
+ $this->envSnapshot = $_ENV;
+ $this->serverSnapshot = $_SERVER;
}
protected function tearDown(): void
{
- parent::tearDown();
- if (file_exists($this->envFile)) {
- unlink($this->envFile);
+ // Restore $_ENV and $_SERVER to pre-test state
+ $addedEnvKeys = array_diff_key($_ENV, $this->envSnapshot);
+ foreach ($addedEnvKeys as $key => $_) {
+ unset($_ENV[$key]);
+ putenv($key);
+ }
+
+ $addedServerKeys = array_diff_key($_SERVER, $this->serverSnapshot);
+ foreach ($addedServerKeys as $key => $_) {
+ unset($_SERVER[$key]);
}
+
+ // Restore any values that were overwritten
+ $_ENV = $this->envSnapshot;
+ $_SERVER = $this->serverSnapshot;
+
+ $this->removeDirectory($this->fixturesDir);
+ }
+
+ // ── Loading ───────────────────────────────────────────────────────
+
+ public function testLoadsEnvFile(): void
+ {
+ $this->createEnvFile('.env', <<<'ENV'
+ TEST_STRING=hello world
+ TEST_INT=42
+ TEST_FLOAT=3.14
+ TEST_BOOL=true
+ TEST_NULL=null
+ TEST_JSON={"key": "value"}
+ TEST_ARRAY=["a", "b", "c"]
+ ENV);
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ self::assertTrue($dotenv->isLoaded());
+
+ // Typed values via get()
+ self::assertSame('hello world', $dotenv->get('TEST_STRING'));
+ self::assertSame(42, $dotenv->get('TEST_INT'));
+ self::assertSame(3.14, $dotenv->get('TEST_FLOAT'));
+ self::assertTrue($dotenv->get('TEST_BOOL'));
+ self::assertNull($dotenv->get('TEST_NULL'));
+ self::assertSame(['key' => 'value'], $dotenv->get('TEST_JSON'));
+ self::assertSame(['a', 'b', 'c'], $dotenv->get('TEST_ARRAY'));
+
+ // Raw values in $_ENV
+ self::assertSame('42', $_ENV['TEST_INT']);
+ self::assertSame('true', $_ENV['TEST_BOOL']);
+ }
+
+ public function testEnvHelperFunction(): void
+ {
+ $this->createEnvFile('.env', <<<'ENV'
+ TEST_INT=99
+ TEST_BOOL=false
+ ENV);
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ self::assertSame(99, env('TEST_INT'));
+ self::assertFalse(env('TEST_BOOL'));
+ self::assertSame('default', env('NONEXISTENT', 'default'));
+ }
+
+ public function testVariableInterpolation(): void
+ {
+ $this->createEnvFile('.env', <<<'ENV'
+ APP_NAME=KaririCode
+ GREETING="Welcome to ${APP_NAME}"
+ ENV);
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ self::assertSame('Welcome to KaririCode', $dotenv->get('GREETING'));
+ }
+
+ // ── Load Modes ────────────────────────────────────────────────────
+
+ public function testSkipExistingMode(): void
+ {
+ $_ENV['IMMUTABLE_VAR'] = 'original';
+
+ $this->createEnvFile('.env', "IMMUTABLE_VAR=overwritten");
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::SkipExisting);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ self::assertSame('original', $_ENV['IMMUTABLE_VAR']);
+ }
+
+ public function testOverwriteMode(): void
+ {
+ $_ENV['IMMUTABLE_VAR'] = 'original';
+
+ $this->createEnvFile('.env', "IMMUTABLE_VAR=overwritten");
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ self::assertSame('overwritten', $_ENV['IMMUTABLE_VAR']);
+ }
+
+ // ── Validation ────────────────────────────────────────────────────
+
+ public function testRequiredVariablesPass(): void
+ {
+ $this->createEnvFile('.env', "FOO=bar\nBAZ=qux");
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ // Should not throw
+ $dotenv->required('FOO', 'BAZ');
+
+ self::assertTrue(true); // Reached without exception
+ }
+
+ public function testRequiredVariablesThrowOnMissing(): void
+ {
+ $this->createEnvFile('.env', 'FOO=bar');
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('MISSING_VAR');
+
+ $dotenv->required('FOO', 'MISSING_VAR');
+ }
+
+ // ── Error Handling ────────────────────────────────────────────────
+
+ public function testThrowsOnMissingFile(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+
+ $dotenv = new Dotenv($this->fixturesDir . '/nonexistent');
+ $dotenv->load();
+ }
+
+ public function testSafeLoadSkipsMissingFile(): void
+ {
+ $dotenv = new Dotenv($this->fixturesDir . '/nonexistent');
+ $dotenv->safeLoad();
+
+ self::assertTrue($dotenv->isLoaded());
+ self::assertSame([], $dotenv->variables());
+ }
+
+ // ── Multiple Files ────────────────────────────────────────────────
+
+ public function testLoadsMultipleFiles(): void
+ {
+ $this->createEnvFile('.env', "FOO=base\nBAR=base");
+ $this->createEnvFile('.env.local', 'BAR=override');
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config, '.env', '.env.local');
+ $dotenv->load();
+
+ self::assertSame('base', $dotenv->get('FOO'));
+ self::assertSame('override', $dotenv->get('BAR'));
+ }
+
+ // ── Configuration ─────────────────────────────────────────────────
+
+ public function testDisableTypeCasting(): void
+ {
+ $this->createEnvFile('.env', 'TEST_INT=42');
+
+ $config = new DotenvConfiguration(typeCasting: false);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ self::assertSame('42', $dotenv->get('TEST_INT'));
+ }
+
+ // ── Fluent Validation DSL ─────────────────────────────────────────
+
+ public function testValidationDslPasses(): void
+ {
+ $this->createEnvFile('.env', <<<'ENV'
+ DB_HOST=localhost
+ DB_PORT=5432
+ APP_DEBUG=true
+ APP_ENV=production
+ APP_URL=https://example.com
+ ADMIN_EMAIL=admin@example.com
+ ENV);
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ $dotenv->validate()
+ ->required('DB_HOST', 'DB_PORT')
+ ->notEmpty('DB_HOST')
+ ->isInteger('DB_PORT')->between(1, 65535)
+ ->isBoolean('APP_DEBUG')
+ ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ ->url('APP_URL')
+ ->email('ADMIN_EMAIL')
+ ->assert();
+
+ self::assertTrue(true);
+ }
+
+ public function testValidationDslCollectsAllErrors(): void
+ {
+ $this->createEnvFile('.env', <<<'ENV'
+ DB_PORT=invalid
+ APP_ENV=wrong
+ ENV);
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ try {
+ $dotenv->validate()
+ ->required('DB_HOST', 'DB_PORT')
+ ->isInteger('DB_PORT')
+ ->allowedValues('APP_ENV', ['local', 'staging', 'production'])
+ ->assert();
+ self::fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ // DB_HOST missing + DB_PORT not integer + APP_ENV not in allowed
+ self::assertCount(3, $e->errors());
+ }
+ }
+
+ public function testValidationIfPresentSkipsMissing(): void
+ {
+ $this->createEnvFile('.env', 'DB_HOST=localhost');
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ $dotenv->validate()
+ ->ifPresent('REDIS_HOST')->notEmpty()
+ ->assert();
+
+ self::assertTrue(true);
+ }
+
+ public function testValidationCustomCallback(): void
+ {
+ $this->createEnvFile('.env', 'DB_HOST=pgsql:host=localhost');
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ $dotenv->validate()
+ ->custom('DB_HOST', fn (string $v): bool => str_starts_with($v, 'pgsql:'))
+ ->assert();
+
+ self::assertTrue(true);
+ }
+
+ // ── Encryption ────────────────────────────────────────────────────
+
+ public function testEncryptionRoundTrip(): void
+ {
+ $keyPair = KeyPair::generate();
+ $encryptor = new Encryptor($keyPair->privateKey);
+
+ // Create .env with encrypted value
+ $encryptedSecret = $encryptor->encrypt('my-secret-password');
+ $this->createEnvFile('.env', "SECRET={$encryptedSecret}\nFOO=plain");
+
+ $config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
+
+ self::assertSame('my-secret-password', $dotenv->get('SECRET'));
+ self::assertSame('plain', $dotenv->get('FOO'));
+
+ // $_ENV should also have decrypted value
+ self::assertSame('my-secret-password', $_ENV['SECRET']);
}
- public function testLoadEnvironmentVariables(): void
+ public function testPlaintextValuesPassThroughWithEncryptionEnabled(): void
{
- $envContent = <<envFile, $envContent);
+ $keyPair = KeyPair::generate();
- $dotenv = DotenvFactory::create($this->envFile);
+ $this->createEnvFile('.env', 'FOO=plaintext-value');
+
+ $config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
$dotenv->load();
- $this->assertSame('Hello World', $_ENV['STRING_VAR']);
- $this->assertSame(42, $_ENV['INT_VAR']);
- $this->assertSame(3.14, $_ENV['FLOAT_VAR']);
- $this->assertSame(true, $_ENV['BOOL_VAR']);
- $this->assertNull($_ENV['NULL_VAR']);
- $this->assertSame(['1', '2', '3'], $_ENV['ARRAY_VAR']);
- $this->assertSame(['key' => 'value'], $_ENV['JSON_VAR']);
+ self::assertSame('plaintext-value', $dotenv->get('FOO'));
+ }
+
+ // ── Cache ─────────────────────────────────────────────────────────
+
+ public function testCacheDumpAndLoad(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432");
+
+ $cachePath = $this->fixturesDir . '/.env.cache.php';
+
+ // First: load and dump cache
+ $dotenv1 = new Dotenv($this->fixturesDir);
+ $dotenv1->load();
+ $dotenv1->dumpCache($cachePath);
+
+ self::assertFileExists($cachePath);
+
+ // Clean env for second load
+ unset($_ENV['DB_HOST'], $_ENV['DB_PORT'], $_SERVER['DB_HOST'], $_SERVER['DB_PORT']);
+ putenv('DB_HOST');
+ putenv('DB_PORT');
+
+ // Second: load from cache
+ $config = new DotenvConfiguration(
+ loadMode: LoadMode::Overwrite,
+ cachePath: $cachePath,
+ );
+ $dotenv2 = new Dotenv($this->fixturesDir, $config);
+ $dotenv2->load();
+
+ self::assertSame('localhost', $dotenv2->get('DB_HOST'));
+ self::assertSame(5432, $dotenv2->get('DB_PORT'));
+ }
+
+ public function testCacheClear(): void
+ {
+ $cachePath = $this->fixturesDir . '/.env.cache.php';
+ file_put_contents($cachePath, 'fixturesDir);
+ $dotenv->clearCache($cachePath);
+
+ self::assertFileDoesNotExist($cachePath);
+ }
+
+ // ── Environment-Aware Loading (bootEnv) ───────────────────────────
+
+ public function testBootEnvCascadeLoading(): void
+ {
+ $this->createEnvFile('.env', "APP_ENV=staging\nDB_HOST=base-host\nDB_PORT=5432");
+ $this->createEnvFile('.env.local', 'DB_HOST=local-host');
+ $this->createEnvFile('.env.staging', 'DB_PORT=5433');
+ $this->createEnvFile('.env.staging.local', 'DB_NAME=staging_local_db');
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->bootEnv();
+
+ // base → .env.local overrides DB_HOST → .env.staging overrides DB_PORT
+ self::assertSame('local-host', $dotenv->get('DB_HOST'));
+ self::assertSame(5433, $dotenv->get('DB_PORT'));
+ self::assertSame('staging_local_db', $dotenv->get('DB_NAME'));
+ }
+
+ public function testBootEnvWithExplicitEnvironment(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=base\nAPP_ENV=ignored");
+ $this->createEnvFile('.env.production', 'DB_HOST=prod-host');
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->bootEnv('production');
+
+ self::assertSame('prod-host', $dotenv->get('DB_HOST'));
+ }
+
+ public function testBootEnvSkipsTestLocalFile(): void
+ {
+ $this->createEnvFile('.env', 'APP_ENV=test');
+ $this->createEnvFile('.env.test', 'DB_HOST=test-host');
+ $this->createEnvFile('.env.test.local', 'DB_HOST=should-not-load');
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->bootEnv('test');
+
+ // .env.test.local is skipped for "test" environment
+ self::assertSame('test-host', $dotenv->get('DB_HOST'));
}
- public function testCustomTypeDetectorAndCaster(): void
+ public function testBootEnvReadsAppEnvFromEnvironment(): void
{
- $envContent = 'CUSTOM_VAR=custom_value';
- file_put_contents($this->envFile, $envContent);
+ $_ENV['APP_ENV'] = 'production';
+
+ $this->createEnvFile('.env', 'DB_HOST=base');
+ $this->createEnvFile('.env.production', 'DB_HOST=prod-host');
- $dotenv = DotenvFactory::create($this->envFile);
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->bootEnv();
+
+ self::assertSame('prod-host', $dotenv->get('DB_HOST'));
+ }
+
+ // ── Allow/Deny List ───────────────────────────────────────────────
+
+ public function testAllowListFilters(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432\nSECRET=hidden\nAPI_KEY=abc123");
- $customDetector = new class implements TypeDetector {
- public function detect(mixed $value): ?string
- {
- return 'custom_value' === $value ? 'custom' : null;
- }
+ $config = new DotenvConfiguration(
+ loadMode: LoadMode::Overwrite,
+ allowList: ['DB_*'],
+ );
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->load();
- public function getPriority(): int
- {
- return 1000;
- }
- };
+ self::assertSame('localhost', $dotenv->get('DB_HOST'));
+ self::assertSame(5432, $dotenv->get('DB_PORT'));
+ self::assertNull($dotenv->get('SECRET'));
+ self::assertNull($dotenv->get('API_KEY'));
+ }
- $customCaster = new class implements TypeCaster {
- public function cast(mixed $value): string
- {
- return strtoupper((string) $value);
- }
- };
+ public function testDenyListFilters(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=localhost\nSECRET=hidden\nAPI_KEY=abc123");
- $dotenv->addTypeDetector($customDetector);
- $dotenv->addTypeCaster('custom', $customCaster);
+ $config = new DotenvConfiguration(
+ loadMode: LoadMode::Overwrite,
+ denyList: ['SECRET*'],
+ );
+ $dotenv = new Dotenv($this->fixturesDir, $config);
$dotenv->load();
- $this->assertSame('CUSTOM_VALUE', $_ENV['CUSTOM_VAR']);
+ self::assertSame('localhost', $dotenv->get('DB_HOST'));
+ self::assertSame('abc123', $dotenv->get('API_KEY'));
+ self::assertNull($dotenv->get('SECRET'));
}
- public function testInterpolation(): void
+ // ── Processors ────────────────────────────────────────────────────
+
+ public function testCsvProcessorSplitsValues(): void
{
- $envContent = <<envFile, $envContent);
+ $this->createEnvFile('.env', 'ALLOWED_IPS=192.168.1.1, 10.0.0.1, 172.16.0.1');
- $dotenv = DotenvFactory::create($this->envFile);
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->addProcessor('ALLOWED_IPS', new CsvToArrayProcessor());
$dotenv->load();
- $this->assertSame('http://example.com/api', $_ENV['API_URL']);
+ self::assertSame(
+ ['192.168.1.1', '10.0.0.1', '172.16.0.1'],
+ $dotenv->get('ALLOWED_IPS'),
+ );
+ }
+
+ public function testGlobPatternProcessor(): void
+ {
+ $this->createEnvFile('.env', "APP_URL=https://example.com\nAPI_KEY=secret");
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->addProcessor('*_URL', new UrlNormalizerProcessor());
+ $dotenv->load();
+
+ self::assertSame('https://example.com/', $dotenv->get('APP_URL'));
+ self::assertSame('secret', $dotenv->get('API_KEY')); // Not affected
+ }
+
+ // ── Schema Validation ─────────────────────────────────────────────
+
+ public function testSchemaValidationPasses(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432\nAPP_ENV=production");
+
+ $schemaPath = $this->fixturesDir . '/.env.schema';
+ file_put_contents($schemaPath, <<<'SCHEMA'
+ [DB_HOST]
+ required = true
+ notEmpty = true
+
+ [DB_PORT]
+ required = true
+ type = integer
+ min = 1
+ max = 65535
+
+ [APP_ENV]
+ required = true
+ allowed = local, staging, production
+ SCHEMA);
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+ $dotenv->loadWithSchema($schemaPath);
+
+ self::assertTrue(true);
+ }
+
+ public function testSchemaValidationFails(): void
+ {
+ $this->createEnvFile('.env', "DB_PORT=invalid\nAPP_ENV=wrong");
+
+ $schemaPath = $this->fixturesDir . '/.env.schema';
+ file_put_contents($schemaPath, <<<'SCHEMA'
+ [DB_HOST]
+ required = true
+
+ [DB_PORT]
+ required = true
+ type = integer
+
+ [APP_ENV]
+ allowed = local, staging, production
+ SCHEMA);
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config);
+
+ $this->expectException(ValidationException::class);
+
+ $dotenv->loadWithSchema($schemaPath);
+ }
+
+ // ── Debug / Introspection ─────────────────────────────────────────
+
+ public function testDebugReturnsSourceTracking(): void
+ {
+ $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432");
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ $debug = $dotenv->debug();
+
+ self::assertArrayHasKey('DB_HOST', $debug);
+ self::assertSame('.env', $debug['DB_HOST']['source']);
+ self::assertSame('String', $debug['DB_HOST']['type']);
+ self::assertSame('localhost', $debug['DB_HOST']['value']);
+ self::assertFalse($debug['DB_HOST']['overridden']);
+ }
+
+ public function testDebugTracksOverrides(): void
+ {
+ $this->createEnvFile('.env', 'DB_HOST=base');
+ $this->createEnvFile('.env.local', 'DB_HOST=override');
+
+ $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite);
+ $dotenv = new Dotenv($this->fixturesDir, $config, '.env', '.env.local');
+ $dotenv->load();
+
+ $debug = $dotenv->debug();
+
+ self::assertSame('.env.local', $debug['DB_HOST']['source']);
+ self::assertTrue($debug['DB_HOST']['overridden']);
+ self::assertSame('override', $debug['DB_HOST']['value']);
+ }
+
+ // ── ${VAR:+alternate} Syntax ──────────────────────────────────────
+
+ public function testAlternateSyntaxInIntegration(): void
+ {
+ $this->createEnvFile('.env', "REDIS_HOST=redis.local\nHAS_CACHE=\${REDIS_HOST:+yes}");
+
+ $dotenv = new Dotenv($this->fixturesDir);
+ $dotenv->load();
+
+ // TypeSystem casts 'yes' → bool true; verify raw value also carried correctly
+ $vars = $dotenv->variables();
+ self::assertArrayHasKey('HAS_CACHE', $vars);
+ self::assertSame('yes', $vars['HAS_CACHE']->rawValue);
+ // The typed value 'yes' is cast to boolean true by TypeSystem
+ self::assertTrue($dotenv->get('HAS_CACHE'));
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────
+
+ private function createEnvFile(string $filename, string $content): void
+ {
+ file_put_contents(
+ $this->fixturesDir . '/' . $filename,
+ ltrim($content),
+ );
+ }
+
+ private function removeDirectory(string $dir): void
+ {
+ if (! is_dir($dir)) {
+ return;
+ }
+
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+
+ foreach ($items as $item) {
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+ }
+
+ rmdir($dir);
}
}
diff --git a/tests/Integration/TypeSystemIntegrationTest.php b/tests/Integration/TypeSystemIntegrationTest.php
deleted file mode 100644
index d5e25bf..0000000
--- a/tests/Integration/TypeSystemIntegrationTest.php
+++ /dev/null
@@ -1,115 +0,0 @@
-typeSystem = new DotenvTypeSystem();
- }
-
- public function testDefaultTypeDetectionAndCasting(): void
- {
- $testCases = [
- ['input' => 'string', 'expected' => 'string'],
- ['input' => '42', 'expected' => 42],
- ['input' => '3.14', 'expected' => 3.14],
- ['input' => 'true', 'expected' => true],
- ['input' => 'null', 'expected' => null],
- ['input' => '[1,2,3]', 'expected' => [1, 2, 3]],
- ['input' => '{"key":"value"}', 'expected' => ['key' => 'value']],
- ];
-
- foreach ($testCases as $case) {
- $result = $this->typeSystem->processValue($case['input']);
- $this->assertEquals($case['expected'], $result, "Failed processing {$case['input']}");
- }
- }
-
- public function testCustomTypeDetectorAndCaster(): void
- {
- $customDetector = new class implements TypeDetector {
- public function detect(mixed $value): ?string
- {
- return 'CUSTOM' === $value ? 'custom_type' : null;
- }
-
- public function getPriority(): int
- {
- return 1000;
- }
- };
-
- $customCaster = new class implements TypeCaster {
- public function cast(mixed $value): string
- {
- return "Processed: $value";
- }
- };
-
- $this->typeSystem->registerDetector($customDetector);
- $this->typeSystem->registerCaster('custom_type', $customCaster);
-
- $result = $this->typeSystem->processValue('CUSTOM');
- $this->assertSame('Processed: CUSTOM', $result);
- }
-
- public function testDetectorPrioritization(): void
- {
- $lowPriorityDetector = new class implements TypeDetector {
- public function detect(mixed $value): ?string
- {
- return 'low_priority';
- }
-
- public function getPriority(): int
- {
- return 10;
- }
- };
-
- $highPriorityDetector = new class implements TypeDetector {
- public function detect(mixed $value): ?string
- {
- return 'high_priority';
- }
-
- public function getPriority(): int
- {
- return 100;
- }
- };
-
- $this->typeSystem->registerDetector($lowPriorityDetector);
- $this->typeSystem->registerDetector($highPriorityDetector);
-
- $this->typeSystem->registerCaster('low_priority', new class implements TypeCaster {
- public function cast(mixed $value): string
- {
- return "Low: $value";
- }
- });
-
- $this->typeSystem->registerCaster('high_priority', new class implements TypeCaster {
- public function cast(mixed $value): string
- {
- return "High: $value";
- }
- });
-
- $result = $this->typeSystem->processValue('test');
- $this->assertSame('High: test', $result);
- }
-}
diff --git a/tests/Unit/Cache/PhpFileCacheTest.php b/tests/Unit/Cache/PhpFileCacheTest.php
new file mode 100644
index 0000000..f242a07
--- /dev/null
+++ b/tests/Unit/Cache/PhpFileCacheTest.php
@@ -0,0 +1,157 @@
+tempDir = sys_get_temp_dir() . '/kariricode_dotenv_test_' . uniqid();
+ mkdir($this->tempDir, 0o777, true);
+ }
+
+ protected function tearDown(): void
+ {
+ // glob('*') misses dotfiles (e.g. .env.cache.php) — use iterator instead.
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($this->tempDir, \RecursiveDirectoryIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($items as $item) {
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+ }
+ rmdir($this->tempDir);
+ }
+
+ public function testDumpAndLoadRoundTrip(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+ $variables = [
+ 'DB_HOST' => 'localhost',
+ 'DB_PORT' => '5432',
+ 'APP_DEBUG' => 'true',
+ ];
+
+ $cache->dump($path, $variables, 'abc123');
+
+ $loaded = $cache->load($path, 'abc123');
+
+ $this->assertSame($variables, $loaded);
+ }
+
+ public function testLoadReturnsNullWhenFileMissing(): void
+ {
+ $cache = new PhpFileCache();
+ $this->assertNull($cache->load($this->tempDir . '/nonexistent.php'));
+ }
+
+ public function testLoadReturnsNullWhenHashMismatch(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+
+ $cache->dump($path, ['KEY' => 'value'], 'hash_v1');
+
+ // Load with different expected hash
+ $this->assertNull($cache->load($path, 'hash_v2'));
+ }
+
+ public function testLoadIgnoresHashWhenExpectedIsEmpty(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+
+ $cache->dump($path, ['KEY' => 'value'], 'some_hash');
+
+ $loaded = $cache->load($path, '');
+ $this->assertSame(['KEY' => 'value'], $loaded);
+ }
+
+ public function testClearRemovesFile(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+
+ $cache->dump($path, ['KEY' => 'value']);
+ $this->assertFileExists($path);
+
+ $cache->clear($path);
+ $this->assertFileDoesNotExist($path);
+ }
+
+ public function testClearSilentlyIgnoresMissingFile(): void
+ {
+ $cache = new PhpFileCache();
+ $cache->clear($this->tempDir . '/nonexistent.php');
+ $this->assertTrue(true);
+ }
+
+ public function testComputeSourceHashDeterministic(): void
+ {
+ $envFile = $this->tempDir . '/.env';
+ file_put_contents($envFile, "DB_HOST=localhost\n");
+
+ $cache = new PhpFileCache();
+
+ $hash1 = $cache->computeSourceHash([$envFile]);
+ $hash2 = $cache->computeSourceHash([$envFile]);
+
+ $this->assertSame($hash1, $hash2);
+ }
+
+ public function testComputeSourceHashChangesWhenFileChanges(): void
+ {
+ $envFile = $this->tempDir . '/.env';
+ file_put_contents($envFile, "DB_HOST=localhost\n");
+
+ $cache = new PhpFileCache();
+ $hash1 = $cache->computeSourceHash([$envFile]);
+
+ // Touch to change mtime
+ sleep(1);
+ file_put_contents($envFile, "DB_HOST=production\n");
+ clearstatcache();
+
+ $hash2 = $cache->computeSourceHash([$envFile]);
+
+ $this->assertNotSame($hash1, $hash2);
+ }
+
+ public function testDumpedFileIsValidPhp(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+
+ $cache->dump($path, ['A' => 'B'], 'hash');
+
+ $content = file_get_contents($path);
+ $this->assertStringContainsString('assertStringContainsString('Auto-generated', $content);
+ $this->assertStringContainsString("'A' => 'B'", $content);
+ }
+
+ public function testDumpHandlesSpecialCharacters(): void
+ {
+ $cache = new PhpFileCache();
+ $path = $this->tempDir . '/.env.cache.php';
+
+ $variables = [
+ 'DSN' => "pgsql:host=localhost;dbname=ar_online",
+ 'QUOTE' => "He said \"hello\"",
+ 'NEWLINE' => "line1\nline2",
+ ];
+
+ $cache->dump($path, $variables, 'hash');
+ $loaded = $cache->load($path, 'hash');
+
+ $this->assertSame($variables, $loaded);
+ }
+}
diff --git a/tests/Unit/Core/DotenvParserTest.php b/tests/Unit/Core/DotenvParserTest.php
new file mode 100644
index 0000000..0324915
--- /dev/null
+++ b/tests/Unit/Core/DotenvParserTest.php
@@ -0,0 +1,271 @@
+parser = new DotenvParser();
+ }
+
+ // ── Basic Parsing ─────────────────────────────────────────────────
+
+ public function testParsesSimpleKeyValue(): void
+ {
+ $result = $this->parser->parse("FOO=bar\nBAZ=qux");
+
+ self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result);
+ }
+
+ public function testSkipsEmptyLines(): void
+ {
+ $result = $this->parser->parse("FOO=bar\n\n\nBAZ=qux\n");
+
+ self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result);
+ }
+
+ public function testSkipsCommentLines(): void
+ {
+ $result = $this->parser->parse("# This is a comment\nFOO=bar\n# Another comment\nBAZ=qux");
+
+ self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result);
+ }
+
+ public function testStripsExportPrefix(): void
+ {
+ $result = $this->parser->parse("export FOO=bar\nexport BAZ=qux");
+
+ self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result);
+ }
+
+ public function testEmptyValueForBareKey(): void
+ {
+ $result = $this->parser->parse('FOO=');
+
+ self::assertSame(['FOO' => ''], $result);
+ }
+
+ // ── Quoted Values ─────────────────────────────────────────────────
+
+ public function testParsesDoubleQuotedValues(): void
+ {
+ $result = $this->parser->parse('FOO="hello world"');
+
+ self::assertSame(['FOO' => 'hello world'], $result);
+ }
+
+ public function testParsesSingleQuotedValues(): void
+ {
+ $result = $this->parser->parse("FOO='hello world'");
+
+ self::assertSame(['FOO' => 'hello world'], $result);
+ }
+
+ public function testSingleQuotesAreRawLiteral(): void
+ {
+ $result = $this->parser->parse("FOO='hello \$BAR \${BAZ}'");
+
+ self::assertSame(['FOO' => 'hello $BAR ${BAZ}'], $result);
+ }
+
+ public function testDoubleQuotedEscapeSequences(): void
+ {
+ $result = $this->parser->parse('FOO="hello\nworld\ttab"');
+
+ self::assertSame(['FOO' => "hello\nworld\ttab"], $result);
+ }
+
+ public function testDoubleQuotedEscapedQuote(): void
+ {
+ $result = $this->parser->parse('FOO="say \"hello\""');
+
+ self::assertSame(['FOO' => 'say "hello"'], $result);
+ }
+
+ public function testUnterminatedDoubleQuoteThrows(): void
+ {
+ $this->expectException(ParseException::class);
+ $this->expectExceptionMessage('Unterminated');
+
+ $this->parser->parse('FOO="unterminated');
+ }
+
+ public function testUnterminatedSingleQuoteThrows(): void
+ {
+ $this->expectException(ParseException::class);
+ $this->expectExceptionMessage('Unterminated');
+
+ $this->parser->parse("FOO='unterminated");
+ }
+
+ // ── Inline Comments ───────────────────────────────────────────────
+
+ public function testStripsInlineComments(): void
+ {
+ $result = $this->parser->parse('FOO=bar # this is a comment');
+
+ self::assertSame(['FOO' => 'bar'], $result);
+ }
+
+ public function testHashInsideQuotesIsNotComment(): void
+ {
+ $result = $this->parser->parse('FOO="bar # not a comment"');
+
+ self::assertSame(['FOO' => 'bar # not a comment'], $result);
+ }
+
+ // ── Variable Interpolation ────────────────────────────────────────
+
+ public function testExpandsBraceVariables(): void
+ {
+ $result = $this->parser->parse("FOO=hello\nBAR=\${FOO} world");
+
+ self::assertSame(['FOO' => 'hello', 'BAR' => 'hello world'], $result);
+ }
+
+ public function testExpandsBareVariables(): void
+ {
+ $result = $this->parser->parse("FOO=hello\nBAR=\$FOO world");
+
+ self::assertSame(['FOO' => 'hello', 'BAR' => 'hello world'], $result);
+ }
+
+ public function testExpandsVariablesInDoubleQuotes(): void
+ {
+ $result = $this->parser->parse("APP=KaririCode\nGREET=\"Welcome to \${APP}\"");
+
+ self::assertSame(['APP' => 'KaririCode', 'GREET' => 'Welcome to KaririCode'], $result);
+ }
+
+ public function testDefaultValueSyntax(): void
+ {
+ $result = $this->parser->parse('FOO=${UNDEFINED:-fallback}');
+
+ self::assertSame(['FOO' => 'fallback'], $result);
+ }
+
+ public function testDefaultValueIgnoredWhenDefined(): void
+ {
+ $result = $this->parser->parse("VAR=hello\nFOO=\${VAR:-fallback}");
+
+ self::assertSame(['VAR' => 'hello', 'FOO' => 'hello'], $result);
+ }
+
+ public function testAlternateSyntaxUsesAlternateWhenDefined(): void
+ {
+ $result = $this->parser->parse("HOST=redis.local\nHAS_CACHE=\${HOST:+yes}");
+
+ self::assertSame([
+ 'HOST' => 'redis.local',
+ 'HAS_CACHE' => 'yes',
+ ], $result);
+ }
+
+ public function testAlternateSyntaxReturnsEmptyWhenUndefined(): void
+ {
+ $result = $this->parser->parse('CACHE=${UNDEFINED_HOST:+redis://fallback}');
+
+ self::assertSame(['CACHE' => ''], $result);
+ }
+
+ public function testAlternateSyntaxReturnsEmptyWhenEmpty(): void
+ {
+ $result = $this->parser->parse("HOST=\nHAS_CACHE=\${HOST:+yes}");
+
+ self::assertSame(['HOST' => '', 'HAS_CACHE' => ''], $result);
+ }
+
+ public function testUndefinedVariableResolvesToEmpty(): void
+ {
+ $result = $this->parser->parse('FOO=$NONEXISTENT');
+
+ self::assertSame(['FOO' => ''], $result);
+ }
+
+ // ── Multiline Values ──────────────────────────────────────────────
+
+ public function testMultilineDoubleQuotedValue(): void
+ {
+ $content = "FOO=\"line1\nline2\nline3\"";
+ $result = $this->parser->parse($content);
+
+ self::assertSame(['FOO' => "line1\nline2\nline3"], $result);
+ }
+
+ // ── Strict Name Validation ────────────────────────────────────────
+
+ public function testStrictNamesRejectsLowercase(): void
+ {
+ $this->expectException(ParseException::class);
+ $this->expectExceptionMessage('Invalid variable name');
+
+ $this->parser->parse('foo_bar=value', '.env', strictNames: true);
+ }
+
+ public function testStrictNamesAcceptsUppercase(): void
+ {
+ $result = $this->parser->parse('FOO_BAR=value', '.env', strictNames: true);
+
+ self::assertSame(['FOO_BAR' => 'value'], $result);
+ }
+
+ public function testNonStrictAcceptsLowercase(): void
+ {
+ $result = $this->parser->parse('foo_bar=value', '.env', strictNames: false);
+
+ self::assertSame(['foo_bar' => 'value'], $result);
+ }
+
+ // ── Edge Cases ────────────────────────────────────────────────────
+
+ public function testEmptyContent(): void
+ {
+ $result = $this->parser->parse('');
+
+ self::assertSame([], $result);
+ }
+
+ public function testCommentOnlyContent(): void
+ {
+ $result = $this->parser->parse("# Just comments\n# Nothing else");
+
+ self::assertSame([], $result);
+ }
+
+ public function testValueWithEqualsSign(): void
+ {
+ $result = $this->parser->parse('CONNECTION=postgresql://user:pass@host/db?sslmode=require');
+
+ self::assertSame(['CONNECTION' => 'postgresql://user:pass@host/db?sslmode=require'], $result);
+ }
+
+ public function testWindowsLineEndings(): void
+ {
+ $result = $this->parser->parse("FOO=bar\r\nBAZ=qux\r\n");
+
+ self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result);
+ }
+
+ public function testValueWithSpacesUnquoted(): void
+ {
+ $result = $this->parser->parse('FOO=hello world');
+
+ self::assertSame(['FOO' => 'hello world'], $result);
+ }
+
+ public function testEscapedDollarInDoubleQuotes(): void
+ {
+ $result = $this->parser->parse('PRICE="\\$100"');
+
+ self::assertSame(['PRICE' => '$100'], $result);
+ }
+}
diff --git a/tests/Unit/DotenvFactoryTest.php b/tests/Unit/DotenvFactoryTest.php
deleted file mode 100644
index cfbc685..0000000
--- a/tests/Unit/DotenvFactoryTest.php
+++ /dev/null
@@ -1,50 +0,0 @@
-assertInstanceOf(Dotenv::class, $dotenv);
-
- $reflection = new \ReflectionClass($dotenv);
-
- $loaderProp = $reflection->getProperty('loader');
- $loaderProp->setAccessible(true);
- $loader = $loaderProp->getValue($dotenv);
-
- $parserProp = $reflection->getProperty('parser');
- $parserProp->setAccessible(true);
- $parser = $parserProp->getValue($dotenv);
-
- $this->assertInstanceOf(FileLoader::class, $loader);
- $this->assertInstanceOf(DefaultParser::class, $parser);
- }
-
- public function testCreateWithStrictMode(): void
- {
- $path = '/path/to/.env';
- $dotenv = DotenvFactory::create($path, true);
-
- $reflection = new \ReflectionClass($dotenv);
-
- $parserProp = $reflection->getProperty('parser');
- $parserProp->setAccessible(true);
- $parser = $parserProp->getValue($dotenv);
-
- $this->assertInstanceOf(StrictParser::class, $parser);
- }
-}
diff --git a/tests/Unit/DotenvTest.php b/tests/Unit/DotenvTest.php
deleted file mode 100644
index d731646..0000000
--- a/tests/Unit/DotenvTest.php
+++ /dev/null
@@ -1,78 +0,0 @@
-loader = $this->createMock(Loader::class);
- $this->parser = $this->createMock(Parser::class);
- $this->typeSystem = $this->createMock(TypeSystem::class);
- $this->dotenv = new Dotenv($this->parser, $this->loader, $this->typeSystem);
- }
-
- public function testLoad(): void
- {
- $this->loader->expects($this->once())
- ->method('load')
- ->willReturn('KEY=value');
-
- $this->parser->expects($this->once())
- ->method('parse')
- ->with('KEY=value')
- ->willReturn(['KEY' => 'value']);
-
- $this->typeSystem->expects($this->once())
- ->method('processValue')
- ->with('value')
- ->willReturn('processed_value');
-
- $this->dotenv->load();
-
- $this->assertEquals('processed_value', $_ENV['KEY']);
- $this->assertEquals('processed_value', $_SERVER['KEY']);
- }
-
- public function testAddTypeDetector(): void
- {
- $detector = $this->createMock(TypeDetector::class);
-
- $this->typeSystem->expects($this->once())
- ->method('registerDetector')
- ->with($detector);
-
- $result = $this->dotenv->addTypeDetector($detector);
-
- $this->assertSame($this->dotenv, $result);
- }
-
- public function testAddTypeCaster(): void
- {
- $caster = $this->createMock(TypeCaster::class);
-
- $this->typeSystem->expects($this->once())
- ->method('registerCaster')
- ->with('test_type', $caster);
-
- $result = $this->dotenv->addTypeCaster('test_type', $caster);
-
- $this->assertSame($this->dotenv, $result);
- }
-}
diff --git a/tests/Unit/Enum/EnumTest.php b/tests/Unit/Enum/EnumTest.php
new file mode 100644
index 0000000..9313983
--- /dev/null
+++ b/tests/Unit/Enum/EnumTest.php
@@ -0,0 +1,71 @@
+name);
+ self::assertSame('Overwrite', LoadMode::Overwrite->name);
+ self::assertSame('SkipExisting', LoadMode::SkipExisting->name);
+ }
+
+ public function testLoadModeHasThreeCases(): void
+ {
+ self::assertCount(3, LoadMode::cases());
+ }
+
+ public function testLoadModeIsBackedByNothing(): void
+ {
+ // Pure enum (no backing type)
+ $reflection = new \ReflectionEnum(LoadMode::class);
+ self::assertFalse($reflection->isBacked());
+ }
+
+ public function testLoadModeIdentity(): void
+ {
+ self::assertSame(LoadMode::Immutable, LoadMode::Immutable);
+ self::assertNotSame(LoadMode::Immutable, LoadMode::Overwrite);
+ self::assertNotSame(LoadMode::Immutable, LoadMode::SkipExisting);
+ }
+
+ // ── ValueType ─────────────────────────────────────────────────────
+
+ public function testValueTypeCasesExist(): void
+ {
+ self::assertSame('String', ValueType::String->name);
+ self::assertSame('Integer', ValueType::Integer->name);
+ self::assertSame('Float', ValueType::Float->name);
+ self::assertSame('Boolean', ValueType::Boolean->name);
+ self::assertSame('Null', ValueType::Null->name);
+ self::assertSame('Json', ValueType::Json->name);
+ self::assertSame('Array', ValueType::Array->name);
+ }
+
+ public function testValueTypeHasSevenCases(): void
+ {
+ self::assertCount(7, ValueType::cases());
+ }
+
+ public function testValueTypeIsBackedByNothing(): void
+ {
+ $reflection = new \ReflectionEnum(ValueType::class);
+ self::assertFalse($reflection->isBacked());
+ }
+
+ public function testValueTypeIdentity(): void
+ {
+ self::assertSame(ValueType::String, ValueType::String);
+ self::assertNotSame(ValueType::String, ValueType::Integer);
+ self::assertNotSame(ValueType::Boolean, ValueType::Integer);
+ }
+}
diff --git a/tests/Unit/EnvFunctionTest.php b/tests/Unit/EnvFunctionTest.php
deleted file mode 100644
index 933e93c..0000000
--- a/tests/Unit/EnvFunctionTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-assertEquals('env_value', env('TEST_VAR'));
- }
-
- public function testEnvReturnsValueFromServer(): void
- {
- $_SERVER['TEST_VAR'] = 'server_value';
- $this->assertEquals('server_value', env('TEST_VAR'));
- }
-
- public function testEnvReturnsValueFromGetenv(): void
- {
- putenv('TEST_VAR=getenv_value');
- $this->assertEquals('getenv_value', env('TEST_VAR'));
- }
-
- public function testEnvPrioritizesEnvOverServerAndGetenv(): void
- {
- $_ENV['TEST_VAR'] = 'env_value';
- $_SERVER['TEST_VAR'] = 'server_value';
- putenv('TEST_VAR=getenv_value');
- $this->assertEquals('env_value', env('TEST_VAR'));
- }
-
- public function testEnvReturnsDefaultValueWhenNotSet(): void
- {
- $this->assertEquals('default', env('NONEXISTENT_VAR', 'default'));
- }
-
- public function testEnvReturnsNullForNonexistentVarWithoutDefault(): void
- {
- $this->assertNull(env('NONEXISTENT_VAR'));
- }
-
- protected function tearDown(): void
- {
- parent::tearDown();
- $_ENV = [];
- $_SERVER = [];
- putenv('TEST_ENV_VAR=');
- }
-}
diff --git a/tests/Unit/Exception/ExceptionTest.php b/tests/Unit/Exception/ExceptionTest.php
new file mode 100644
index 0000000..55991b4
--- /dev/null
+++ b/tests/Unit/Exception/ExceptionTest.php
@@ -0,0 +1,152 @@
+getMessage());
+ self::assertStringContainsString('not found', $e->getMessage());
+ }
+
+ public function testFileNotFoundExceptionIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(FileNotFoundException::class);
+ self::assertTrue($reflection->isFinal());
+ }
+
+ // ── ImmutableException ────────────────────────────────────────────
+
+ public function testImmutableExceptionAlreadyDefined(): void
+ {
+ $e = ImmutableException::alreadyDefined('DB_HOST');
+
+ self::assertInstanceOf(DotenvException::class, $e);
+ self::assertStringContainsString('DB_HOST', $e->getMessage());
+ self::assertStringContainsString('Immutable', $e->getMessage());
+ }
+
+ public function testImmutableExceptionIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(ImmutableException::class);
+ self::assertTrue($reflection->isFinal());
+ }
+
+ // ── ParseException ────────────────────────────────────────────────
+
+ public function testParseExceptionInvalidLine(): void
+ {
+ $e = ParseException::invalidLine('malformed line', 42, '/app/.env');
+
+ self::assertInstanceOf(DotenvException::class, $e);
+ self::assertStringContainsString('42', $e->getMessage());
+ self::assertStringContainsString('/app/.env', $e->getMessage());
+ self::assertStringContainsString('malformed line', $e->getMessage());
+ }
+
+ public function testParseExceptionInvalidVariableNameStrict(): void
+ {
+ $e = ParseException::invalidVariableName('myVar', 5, '/app/.env', strict: true);
+
+ self::assertStringContainsString('myVar', $e->getMessage());
+ self::assertStringContainsString('uppercase', $e->getMessage());
+ }
+
+ public function testParseExceptionInvalidVariableNameNonStrict(): void
+ {
+ $e = ParseException::invalidVariableName('invalid!', 5, '/app/.env', strict: false);
+
+ self::assertStringContainsString('invalid!', $e->getMessage());
+ self::assertStringContainsString('letters', $e->getMessage());
+ }
+
+ public function testParseExceptionUnterminatedQuote(): void
+ {
+ $e = ParseException::unterminatedQuote(10, '/app/.env');
+
+ self::assertStringContainsString('10', $e->getMessage());
+ self::assertStringContainsString('/app/.env', $e->getMessage());
+ self::assertStringContainsString('Unterminated', $e->getMessage());
+ }
+
+ public function testParseExceptionCircularReference(): void
+ {
+ $e = ParseException::circularReference('SELF_REF', '/app/.env');
+
+ self::assertStringContainsString('SELF_REF', $e->getMessage());
+ self::assertStringContainsString('Circular', $e->getMessage());
+ }
+
+ public function testParseExceptionIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(ParseException::class);
+ self::assertTrue($reflection->isFinal());
+ }
+
+ // ── ValidationException ───────────────────────────────────────────
+
+ public function testValidationExceptionMissingRequired(): void
+ {
+ $e = ValidationException::missingRequired(['DB_HOST', 'DB_PORT']);
+
+ self::assertInstanceOf(DotenvException::class, $e);
+ self::assertStringContainsString('DB_HOST', $e->getMessage());
+ self::assertStringContainsString('DB_PORT', $e->getMessage());
+ self::assertSame([], $e->errors()); // No batch errors
+ }
+
+ public function testValidationExceptionBatchErrors(): void
+ {
+ $errors = ['DB_HOST is required', 'DB_PORT must be integer', 'APP_ENV invalid'];
+ $e = ValidationException::batchErrors($errors);
+
+ self::assertSame($errors, $e->errors());
+ self::assertCount(3, $e->errors());
+ foreach ($errors as $error) {
+ self::assertStringContainsString($error, $e->getMessage());
+ }
+ }
+
+ public function testValidationExceptionSchemaViolation(): void
+ {
+ $e = ValidationException::schemaViolation('DB_PORT must be integer');
+
+ self::assertStringContainsString('Schema', $e->getMessage());
+ self::assertStringContainsString('DB_PORT', $e->getMessage());
+ }
+
+ public function testValidationExceptionErrorsDefaultToEmpty(): void
+ {
+ $e = ValidationException::missingRequired(['X']);
+ self::assertSame([], $e->errors());
+ }
+
+ public function testValidationExceptionIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(ValidationException::class);
+ self::assertTrue($reflection->isFinal());
+ }
+}
diff --git a/tests/Unit/Loader/ArrayLoaderTest.php b/tests/Unit/Loader/ArrayLoaderTest.php
deleted file mode 100644
index 86ec058..0000000
--- a/tests/Unit/Loader/ArrayLoaderTest.php
+++ /dev/null
@@ -1,33 +0,0 @@
- 'value1',
- 'KEY2' => 'value2',
- ];
-
- $loader = new ArrayLoader($variables);
- $result = $loader->load();
-
- $expected = "KEY1=value1\nKEY2=value2\n";
- $this->assertEquals($expected, $result);
- }
-
- public function testLoadWithEmptyArray(): void
- {
- $loader = new ArrayLoader([]);
- $result = $loader->load();
-
- $this->assertEquals('', $result);
- }
-}
diff --git a/tests/Unit/Loader/FileLoaderTest.php b/tests/Unit/Loader/FileLoaderTest.php
deleted file mode 100644
index e1fe866..0000000
--- a/tests/Unit/Loader/FileLoaderTest.php
+++ /dev/null
@@ -1,84 +0,0 @@
-tempDir = sys_get_temp_dir() . '/dotenv_test_' . uniqid();
- mkdir($this->tempDir);
- }
-
- protected function tearDown(): void
- {
- parent::tearDown();
- $this->removeDirectory($this->tempDir);
- }
-
- public function testLoad(): void
- {
- $filePath = $this->tempDir . '/test.env';
- file_put_contents($filePath, 'TEST_KEY=test_value');
-
- $loader = new FileLoader($filePath);
- $result = $loader->load();
-
- $this->assertSame('TEST_KEY=test_value', $result);
- }
-
- public function testLoadNonExistentFile(): void
- {
- $filePath = $this->tempDir . '/non_existent.env';
-
- $loader = new FileLoader($filePath);
-
- $this->expectException(InvalidFileException::class);
- $this->expectExceptionMessage('The environment file ' . $filePath . ' does not exist.');
-
- $loader->load();
- }
-
- public function testLoadUnreadableFile(): void
- {
- $filePath = $this->tempDir . '/unreadable.env';
- file_put_contents($filePath, 'TEST_KEY=test_value');
-
- $loader = $this->getMockBuilder(FileLoader::class)
- ->setConstructorArgs([$filePath])
- ->onlyMethods(['getFileContents'])
- ->getMock();
-
- $loader->expects($this->once())
- ->method('getFileContents')
- ->willReturn(false);
-
- $this->expectException(InvalidFileException::class);
- $this->expectExceptionMessage('Unable to read the environment file at ' . $filePath);
-
- $loader->load();
- }
-
- private function removeDirectory(string $dir): void
- {
- if (!file_exists($dir)) {
- return;
- }
-
- $files = array_diff(scandir($dir), ['.', '..']);
- foreach ($files as $file) {
- $path = $dir . '/' . $file;
- is_dir($path) ? $this->removeDirectory($path) : unlink($path);
- }
- rmdir($dir);
- }
-}
diff --git a/tests/Unit/Parser/DefaultParserTest.php b/tests/Unit/Parser/DefaultParserTest.php
deleted file mode 100644
index f036998..0000000
--- a/tests/Unit/Parser/DefaultParserTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-parser = new DefaultParser();
- }
-
- /**
- * @dataProvider validInputProvider
- */
- public function testParseWithValidInput(string $input, array $expected): void
- {
- $result = $this->parser->parse($input);
- $this->assertEquals($expected, $result);
- }
-
- public static function validInputProvider(): array
- {
- return [
- 'simple key-value' => [
- 'KEY=value',
- ['KEY' => 'value'],
- ],
- 'multiple lines' => [
- "KEY1=value1\nKEY2=value2",
- ['KEY1' => 'value1', 'KEY2' => 'value2'],
- ],
- 'with comments' => [
- "KEY=value\n# This is a comment\nANOTHER_KEY=another_value",
- ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'],
- ],
- 'with empty lines' => [
- "KEY=value\n\nANOTHER_KEY=another_value",
- ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'],
- ],
- 'with interpolation' => [
- "BASE_DIR=/var/www\nAPP_DIR=\${BASE_DIR}/app",
- ['BASE_DIR' => '/var/www', 'APP_DIR' => '/var/www/app'],
- ],
- 'with quotes' => [
- 'QUOTED="This is a quoted value"',
- ['QUOTED' => 'This is a quoted value'],
- ],
- ];
- }
-
- public function testParseWithInvalidName(): void
- {
- $input = "VALID_KEY=value\nINVALID-KEY=value";
- $result = $this->parser->parse($input);
- $this->assertEquals(['VALID_KEY' => 'value', 'INVALID-KEY' => 'value'], $result);
- }
-
- public function testParseWithEmptyName(): void
- {
- $input = '=value';
- $result = $this->parser->parse($input);
- $this->assertEquals([], $result);
- }
-
- public function testInterpolationWithUndefinedVariable(): void
- {
- $input = 'KEY=${UNDEFINED_VAR}';
- $result = $this->parser->parse($input);
- $this->assertEquals(['KEY' => '${UNDEFINED_VAR}'], $result);
- }
-
- public function testMultipleInterpolations(): void
- {
- $input = "BASE=/var\nAPP=/app\nPATH=\${BASE}\${APP}";
- $expected = ['BASE' => '/var', 'APP' => '/app', 'PATH' => '/var/app'];
- $result = $this->parser->parse($input);
- $this->assertEquals($expected, $result);
- }
-
- public function testParsingEmptyContent(): void
- {
- $input = '';
- $this->assertEquals([], $this->parser->parse($input));
- }
-
- public function testParsingOnlyComments(): void
- {
- $input = "# This is a comment\n# Another comment";
- $this->assertEquals([], $this->parser->parse($input));
- }
-}
diff --git a/tests/Unit/Parser/StrictParserTest.php b/tests/Unit/Parser/StrictParserTest.php
deleted file mode 100644
index 10d208b..0000000
--- a/tests/Unit/Parser/StrictParserTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-parser = new StrictParser();
- }
-
- /**
- * @dataProvider validInputProvider
- */
- public function testParseWithValidInput(string $input, array $expected): void
- {
- $result = $this->parser->parse($input);
- $this->assertEquals($expected, $result);
- }
-
- public static function validInputProvider(): array
- {
- return [
- 'simple key-value' => [
- 'KEY=value',
- ['KEY' => 'value'],
- ],
- 'multiple lines' => [
- "KEY1=value1\nKEY2=value2",
- ['KEY1' => 'value1', 'KEY2' => 'value2'],
- ],
- 'with comments' => [
- "KEY=value\n# This is a comment\nANOTHER_KEY=another_value",
- ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'],
- ],
- 'with empty lines' => [
- "KEY=value\n\nANOTHER_KEY=another_value",
- ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'],
- ],
- 'with interpolation' => [
- "BASE_DIR=/var/www\nAPP_DIR=\${BASE_DIR}/app",
- ['BASE_DIR' => '/var/www', 'APP_DIR' => '/var/www/app'],
- ],
- 'with quotes' => [
- 'QUOTED="This is a quoted value"',
- ['QUOTED' => 'This is a quoted value'],
- ],
- ];
- }
-
- public function testParseWithInvalidName(): void
- {
- $this->expectException(InvalidValueException::class);
- $this->expectExceptionMessage('Invalid character in variable name');
-
- $input = "VALID_KEY=value\nINVALID-KEY=value";
- $this->parser->parse($input);
- }
-
- public function testParseWithEmptyName(): void
- {
- $this->expectException(InvalidValueException::class);
- $this->expectExceptionMessage('Empty variable name');
-
- $input = '=value';
- $this->parser->parse($input);
- }
-
- public function testParseWithInvalidStartingCharacter(): void
- {
- $this->expectException(InvalidValueException::class);
- $this->expectExceptionMessage('Variable name must start with a letter or underscore');
-
- $input = '1INVALID=value';
- $this->parser->parse($input);
- }
-
- /**
- * @dataProvider invalidCharactersProvider
- */
- public function testParseWithInvalidCharacters(string $input): void
- {
- $this->expectException(InvalidValueException::class);
- $this->expectExceptionMessage('Invalid character in variable name');
-
- $this->parser->parse($input);
- }
-
- public static function invalidCharactersProvider(): array
- {
- return [
- 'with space' => ['INVALID KEY=value'],
- 'with dash' => ['INVALID-KEY=value'],
- 'with dot' => ['INVALID.KEY=value'],
- 'with comma' => ['INVALID,KEY=value'],
- 'with brackets' => ['INVALID[KEY]=value'],
- ];
- }
-
- public function testInterpolationWithUndefinedVariable(): void
- {
- $input = 'KEY=${UNDEFINED_VAR}';
- $result = $this->parser->parse($input);
- $this->assertEquals(['KEY' => '${UNDEFINED_VAR}'], $result);
- }
-
- public function testMultipleInterpolations(): void
- {
- $input = "BASE=/var\nAPP=/app\nPATH=\${BASE}\${APP}";
- $expected = ['BASE' => '/var', 'APP' => '/app', 'PATH' => '/var/app'];
- $result = $this->parser->parse($input);
- $this->assertEquals($expected, $result);
- }
-
- public function testParsingEmptyContent(): void
- {
- $input = '';
- $this->assertEquals([], $this->parser->parse($input));
- }
-
- public function testParsingOnlyComments(): void
- {
- $input = "# This is a comment\n# Another comment";
- $this->assertEquals([], $this->parser->parse($input));
- }
-}
diff --git a/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php b/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php
deleted file mode 100644
index 49aac6d..0000000
--- a/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php
+++ /dev/null
@@ -1,146 +0,0 @@
-assertEquals($expected, $this->isValidSetter($line));
- }
-
- public static function validSetterProvider(): array
- {
- return [
- 'valid setter' => ['KEY=value', true],
- 'setter with spaces' => [' KEY = value ', true],
- 'comment' => ['# This is a comment', false],
- 'empty line' => ['', false],
- 'invalid line' => ['This is not a valid setter', false],
- ];
- }
-
- /**
- * @dataProvider commentProvider
- */
- public function testIsComment(string $line, bool $expected): void
- {
- $this->assertEquals($expected, $this->isComment($line));
- }
-
- public static function commentProvider(): array
- {
- return [
- 'comment' => ['# This is a comment', true],
- 'comment with leading space' => [' # This is a comment', true],
- 'comment with multiple leading spaces' => [' # This is a comment', true],
- 'not a comment' => ['This is not a comment', false],
- 'empty line' => ['', false],
- 'line with only spaces' => [' ', false],
- ];
- }
-
- /**
- * @dataProvider setterCharProvider
- */
- public function testContainsSetterChar(string $line, bool $expected): void
- {
- $this->assertEquals($expected, $this->containsSetterChar($line));
- }
-
- public static function setterCharProvider(): array
- {
- return [
- 'contains setter char' => ['KEY=value', true],
- 'multiple setter chars' => ['KEY=value=another', true],
- 'no setter char' => ['This is a line without setter char', false],
- 'empty line' => ['', false],
- ];
- }
-
- /**
- * @dataProvider parseEnvironmentVariableProvider
- */
- public function testParseEnvironmentVariable(string $line, array $expected): void
- {
- $this->assertEquals($expected, $this->parseEnvironmentVariable($line));
- }
-
- public static function parseEnvironmentVariableProvider(): array
- {
- return [
- 'simple variable' => ['KEY=value', ['KEY', 'value']],
- 'variable with spaces' => [' KEY = value ', ['KEY', 'value']],
- 'variable with multiple equal signs' => ['KEY=value=another', ['KEY', 'value=another']],
- 'empty value' => ['KEY=', ['KEY', '']],
- 'invalid line' => ['This is not a valid variable', [null, null]],
- ];
- }
-
- /**
- * @dataProvider sanitizeValueProvider
- */
- public function testSanitizeValue(string $value, string $expected): void
- {
- $this->assertEquals($expected, $this->sanitizeValue($value));
- }
-
- public static function sanitizeValueProvider(): array
- {
- return [
- 'unquoted value' => ['value', 'value'],
- 'single quoted value' => ["'value'", 'value'],
- 'double quoted value' => ['"value"', 'value'],
- 'value with spaces' => [' value ', 'value'],
- 'value with internal spaces' => [' value with spaces ', 'value with spaces'],
- 'empty value' => ['', ''],
- ];
- }
-
- /**
- * @dataProvider removeQuotesProvider
- */
- public function testRemoveQuotes(string $value, string $expected): void
- {
- $this->assertEquals($expected, $this->removeQuotes($value));
- }
-
- public static function removeQuotesProvider(): array
- {
- return [
- 'unquoted value' => ['value', 'value'],
- 'single quoted value' => ["'value'", 'value'],
- 'double quoted value' => ['"value"', 'value'],
- 'mixed quotes' => ['"\'value\'"', "'value'"],
- 'empty value' => ['', ''],
- 'value with internal quotes' => ['"value \'quoted\' inside"', "value 'quoted' inside"],
- ];
- }
-
- /**
- * @dataProvider isValidKeyProvider
- */
- public function testIsValidKey(?string $key, bool $expected): void
- {
- $this->assertEquals($expected, $this->isValidKey($key));
- }
-
- public static function isValidKeyProvider(): array
- {
- return [
- 'valid key' => ['KEY', true],
- 'empty string' => ['', false],
- 'null' => [null, false],
- ];
- }
-}
diff --git a/tests/Unit/Processor/ProcessorTest.php b/tests/Unit/Processor/ProcessorTest.php
new file mode 100644
index 0000000..cca4afe
--- /dev/null
+++ b/tests/Unit/Processor/ProcessorTest.php
@@ -0,0 +1,109 @@
+process($raw, $raw),
+ );
+ }
+
+ public function testCsvToArrayReturnsEmptyForBlank(): void
+ {
+ $processor = new CsvToArrayProcessor();
+
+ self::assertSame([], $processor->process('', ''));
+ self::assertSame([], $processor->process(' ', ' '));
+ }
+
+ public function testCsvToArrayWithCustomSeparator(): void
+ {
+ $processor = new CsvToArrayProcessor(separator: '|');
+ $raw = 'a|b|c';
+
+ self::assertSame(
+ ['a', 'b', 'c'],
+ $processor->process($raw, $raw),
+ );
+ }
+
+ // ── Base64DecodeProcessor ─────────────────────────────────────────
+
+ public function testBase64DecodeProcessesValidInput(): void
+ {
+ $processor = new Base64DecodeProcessor();
+ $encoded = base64_encode('secret-key');
+
+ self::assertSame('secret-key', $processor->process($encoded, $encoded));
+ }
+
+ public function testBase64DecodeThrowsOnInvalidInput(): void
+ {
+ $processor = new Base64DecodeProcessor();
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Invalid base64');
+
+ $processor->process('!!!invalid!!!', '!!!invalid!!!');
+ }
+
+ // ── TrimProcessor ─────────────────────────────────────────────────
+
+ public function testTrimRemovesWhitespace(): void
+ {
+ $processor = new TrimProcessor();
+ $raw = " hello\t\n";
+
+ self::assertSame('hello', $processor->process($raw, $raw));
+ }
+
+ public function testTrimWithCustomCharacters(): void
+ {
+ $processor = new TrimProcessor(characters: '/');
+
+ self::assertSame('path', $processor->process('/path/', '/path/'));
+ }
+
+ // ── UrlNormalizerProcessor ────────────────────────────────────────
+
+ public function testUrlNormalizerAddsTrailingSlash(): void
+ {
+ $processor = new UrlNormalizerProcessor();
+ $raw = 'https://api.example.com';
+
+ self::assertSame('https://api.example.com/', $processor->process($raw, $raw));
+ }
+
+ public function testUrlNormalizerPreservesExistingSlash(): void
+ {
+ $processor = new UrlNormalizerProcessor();
+ $raw = 'https://api.example.com/';
+
+ self::assertSame('https://api.example.com/', $processor->process($raw, $raw));
+ }
+
+ public function testUrlNormalizerRemovesDoubleSlash(): void
+ {
+ $processor = new UrlNormalizerProcessor();
+ $raw = 'https://api.example.com//';
+
+ self::assertSame('https://api.example.com/', $processor->process($raw, $raw));
+ }
+}
diff --git a/tests/Unit/Schema/SchemaParserTest.php b/tests/Unit/Schema/SchemaParserTest.php
new file mode 100644
index 0000000..3966a36
--- /dev/null
+++ b/tests/Unit/Schema/SchemaParserTest.php
@@ -0,0 +1,216 @@
+parser = new SchemaParser();
+ }
+
+ // ── Parsing ───────────────────────────────────────────────────────
+
+ public function testParsesBasicSchema(): void
+ {
+ $content = <<<'SCHEMA'
+ [DB_HOST]
+ required = true
+ type = string
+ notEmpty = true
+
+ [DB_PORT]
+ required = true
+ type = integer
+ min = 1
+ max = 65535
+ SCHEMA;
+
+ $schema = $this->parser->parse($content);
+
+ self::assertArrayHasKey('DB_HOST', $schema);
+ self::assertArrayHasKey('DB_PORT', $schema);
+ self::assertSame('true', $schema['DB_HOST']['required']);
+ self::assertSame('string', $schema['DB_HOST']['type']);
+ self::assertSame('true', $schema['DB_HOST']['notEmpty']);
+ self::assertSame('integer', $schema['DB_PORT']['type']);
+ self::assertSame('1', $schema['DB_PORT']['min']);
+ self::assertSame('65535', $schema['DB_PORT']['max']);
+ }
+
+ public function testSkipsCommentsAndEmptyLines(): void
+ {
+ $content = <<<'SCHEMA'
+ # This is a header comment
+ ; INI-style comment
+
+ [APP_ENV]
+ required = true
+ allowed = local, staging, production
+ SCHEMA;
+
+ $schema = $this->parser->parse($content);
+
+ self::assertCount(1, $schema);
+ self::assertArrayHasKey('APP_ENV', $schema);
+ }
+
+ public function testParsesAllowedValues(): void
+ {
+ $content = <<<'SCHEMA'
+ [APP_ENV]
+ allowed = local, staging, production
+ SCHEMA;
+
+ $schema = $this->parser->parse($content);
+
+ self::assertSame('local, staging, production', $schema['APP_ENV']['allowed']);
+ }
+
+ public function testParsesRegexDirective(): void
+ {
+ $content = <<<'SCHEMA'
+ [API_KEY]
+ regex = /^[a-f0-9]{32}$/
+ SCHEMA;
+
+ $schema = $this->parser->parse($content);
+
+ self::assertSame('/^[a-f0-9]{32}$/', $schema['API_KEY']['regex']);
+ }
+
+ // ── Validator Application ─────────────────────────────────────────
+
+ public function testApplyToValidatorPassesWithValidData(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [DB_HOST]
+ required = true
+ notEmpty = true
+
+ [DB_PORT]
+ required = true
+ type = integer
+ min = 1
+ max = 65535
+ SCHEMA);
+
+ $variables = ['DB_HOST' => 'localhost', 'DB_PORT' => '5432'];
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => $variables[$name] ?? null,
+ );
+
+ $this->parser->applyToValidator($schema, $validator);
+ $validator->assert();
+
+ $this->assertTrue(true);
+ }
+
+ public function testApplyToValidatorFailsOnMissingRequired(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [DB_HOST]
+ required = true
+ SCHEMA);
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => null,
+ );
+
+ $this->parser->applyToValidator($schema, $validator);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('DB_HOST');
+
+ $validator->assert();
+ }
+
+ public function testApplyToValidatorFailsOnInvalidType(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [DB_PORT]
+ required = true
+ type = integer
+ SCHEMA);
+
+ $variables = ['DB_PORT' => 'not-a-number'];
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => $variables[$name] ?? null,
+ );
+
+ $this->parser->applyToValidator($schema, $validator);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be an integer');
+
+ $validator->assert();
+ }
+
+ public function testApplyToValidatorWithAllowedValues(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [APP_ENV]
+ required = true
+ allowed = local, staging, production
+ SCHEMA);
+
+ $variables = ['APP_ENV' => 'invalid'];
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => $variables[$name] ?? null,
+ );
+
+ $this->parser->applyToValidator($schema, $validator);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be one of');
+
+ $validator->assert();
+ }
+
+ public function testApplyToValidatorSkipsOptionalMissing(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [OPTIONAL_VAR]
+ required = false
+ type = integer
+ SCHEMA);
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => null,
+ );
+
+ $this->parser->applyToValidator($schema, $validator);
+ $validator->assert();
+
+ $this->assertTrue(true);
+ }
+
+ public function testUnknownTypeThrows(): void
+ {
+ $schema = $this->parser->parse(<<<'SCHEMA'
+ [VAR]
+ type = uuid
+ SCHEMA);
+
+ $validator = new EnvironmentValidator(
+ fn (string $name): ?string => 'test',
+ );
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage("Unknown type 'uuid'");
+
+ $this->parser->applyToValidator($schema, $validator);
+ }
+}
diff --git a/tests/Unit/Security/EncryptorTest.php b/tests/Unit/Security/EncryptorTest.php
new file mode 100644
index 0000000..982a417
--- /dev/null
+++ b/tests/Unit/Security/EncryptorTest.php
@@ -0,0 +1,134 @@
+hexKey = bin2hex(random_bytes(32));
+ }
+
+ public function testEncryptDecryptRoundTrip(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $plaintext = 'super-secret-database-password';
+
+ $encrypted = $encryptor->encrypt($plaintext);
+ $decrypted = $encryptor->decrypt($encrypted);
+
+ $this->assertSame($plaintext, $decrypted);
+ }
+
+ public function testEncryptedValueHasPrefix(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $encrypted = $encryptor->encrypt('test');
+
+ $this->assertTrue(Encryptor::isEncrypted($encrypted));
+ $this->assertStringStartsWith('encrypted:', $encrypted);
+ }
+
+ public function testDecryptReturnsPlaintextIfNotEncrypted(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $plaintext = 'just-a-regular-value';
+
+ $this->assertSame($plaintext, $encryptor->decrypt($plaintext));
+ }
+
+ public function testIsEncryptedDetectsPrefix(): void
+ {
+ $this->assertTrue(Encryptor::isEncrypted('encrypted:abc123'));
+ $this->assertFalse(Encryptor::isEncrypted('plain-value'));
+ $this->assertFalse(Encryptor::isEncrypted(''));
+ }
+
+ public function testDifferentNoncesProduceDifferentCiphertexts(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $plaintext = 'same-input';
+
+ $enc1 = $encryptor->encrypt($plaintext);
+ $enc2 = $encryptor->encrypt($plaintext);
+
+ $this->assertNotSame($enc1, $enc2);
+
+ // Both decrypt to the same plaintext
+ $this->assertSame($plaintext, $encryptor->decrypt($enc1));
+ $this->assertSame($plaintext, $encryptor->decrypt($enc2));
+ }
+
+ public function testWrongKeyFailsDecryption(): void
+ {
+ $encryptor1 = new Encryptor($this->hexKey);
+ $encrypted = $encryptor1->encrypt('secret');
+
+ $wrongKey = bin2hex(random_bytes(32));
+ $encryptor2 = new Encryptor($wrongKey);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Decryption failed');
+
+ $encryptor2->decrypt($encrypted);
+ }
+
+ public function testCorruptedPayloadThrows(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+
+ $this->expectException(\RuntimeException::class);
+
+ $encryptor->decrypt('encrypted:' . base64_encode('short'));
+ }
+
+ public function testInvalidKeyLengthThrows(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('32 bytes');
+
+ new Encryptor('too-short');
+ }
+
+ public function testAcceptsRawBinaryKey(): void
+ {
+ $rawKey = random_bytes(32);
+ $encryptor = new Encryptor($rawKey);
+
+ $encrypted = $encryptor->encrypt('binary-key-test');
+ $this->assertSame('binary-key-test', $encryptor->decrypt($encrypted));
+ }
+
+ public function testEncryptsEmptyString(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+
+ $encrypted = $encryptor->encrypt('');
+ $this->assertSame('', $encryptor->decrypt($encrypted));
+ }
+
+ public function testEncryptsUnicode(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $plaintext = 'Régua de cobrança — ICP-Brasil 🇧🇷';
+
+ $encrypted = $encryptor->encrypt($plaintext);
+ $this->assertSame($plaintext, $encryptor->decrypt($encrypted));
+ }
+
+ public function testEncryptsLargePayload(): void
+ {
+ $encryptor = new Encryptor($this->hexKey);
+ $plaintext = str_repeat('A', 10_000);
+
+ $encrypted = $encryptor->encrypt($plaintext);
+ $this->assertSame($plaintext, $encryptor->decrypt($encrypted));
+ }
+}
diff --git a/tests/Unit/Security/KeyPairTest.php b/tests/Unit/Security/KeyPairTest.php
new file mode 100644
index 0000000..721e31a
--- /dev/null
+++ b/tests/Unit/Security/KeyPairTest.php
@@ -0,0 +1,60 @@
+assertSame(64, \strlen($kp->privateKey));
+ $this->assertTrue(ctype_xdigit($kp->privateKey));
+ $this->assertSame(8, \strlen($kp->publicId));
+ }
+
+ public function testGenerateProducesUniqueKeys(): void
+ {
+ $kp1 = KeyPair::generate();
+ $kp2 = KeyPair::generate();
+
+ $this->assertNotSame($kp1->privateKey, $kp2->privateKey);
+ }
+
+ public function testFromPrivateKeyReconstitutes(): void
+ {
+ $original = KeyPair::generate();
+ $restored = KeyPair::fromPrivateKey($original->privateKey);
+
+ $this->assertSame($original->privateKey, $restored->privateKey);
+ $this->assertSame($original->publicId, $restored->publicId);
+ }
+
+ public function testFromPrivateKeyRejectsInvalidLength(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ KeyPair::fromPrivateKey('abcdef');
+ }
+
+ public function testFromPrivateKeyRejectsNonHex(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ KeyPair::fromPrivateKey(str_repeat('g', 64));
+ }
+
+ public function testGeneratedKeyWorksWithEncryptor(): void
+ {
+ $kp = KeyPair::generate();
+ $encryptor = new \KaririCode\Dotenv\Security\Encryptor($kp->privateKey);
+
+ $encrypted = $encryptor->encrypt('test-value');
+ $this->assertSame('test-value', $encryptor->decrypt($encrypted));
+ }
+}
diff --git a/tests/Unit/Type/Caster/ArrayCasterTest.php b/tests/Unit/Type/Caster/ArrayCasterTest.php
deleted file mode 100644
index 39d6b1c..0000000
--- a/tests/Unit/Type/Caster/ArrayCasterTest.php
+++ /dev/null
@@ -1,54 +0,0 @@
-arrayCaster = new ArrayCaster();
- }
-
- /**
- * @dataProvider validArrayStringProvider
- */
- public function testCastValidArrayString(string $input, array $expected): void
- {
- $result = $this->arrayCaster->cast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function validArrayStringProvider(): array
- {
- return [
- 'simple array' => ['[1, 2, 3]', ['1', '2', '3']],
- 'array with spaces' => ['[ 1, 2, 3 ]', ['1', '2', '3']],
- 'array with strings' => ['["a", "b", "c"]', ['a', 'b', 'c']],
- 'array with mixed types' => ['[1, "two", 3.0]', ['1', 'two', '3.0']],
- 'empty array' => ['[]', []],
- 'array with single item' => ['[42]', ['42']],
- ];
- }
-
- public function testCastNonStringValue(): void
- {
- $input = ['already', 'an', 'array'];
- $result = $this->arrayCaster->cast($input);
- $this->assertSame($input, $result);
- }
-
- public function testCastNonArrayString(): void
- {
- $input = 'not an array';
- $result = $this->arrayCaster->cast($input);
- $this->assertSame(['not an array'], $result);
- }
-}
diff --git a/tests/Unit/Type/Caster/BooleanCasterTest.php b/tests/Unit/Type/Caster/BooleanCasterTest.php
deleted file mode 100644
index 9dcbad8..0000000
--- a/tests/Unit/Type/Caster/BooleanCasterTest.php
+++ /dev/null
@@ -1,83 +0,0 @@
-booleanCaster = new BooleanCaster();
- }
-
- /**
- * @dataProvider booleanValuesProvider
- */
- public function testCast(mixed $input, bool $expected): void
- {
- $result = $this->booleanCaster->cast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function booleanValuesProvider(): array
- {
- return [
- 'true string' => ['true', true],
- 'false string' => ['false', false],
- '1 string' => ['1', true],
- '0 string' => ['0', false],
- 'yes string' => ['yes', true],
- 'no string' => ['no', false],
- 'on string' => ['on', true],
- 'off string' => ['off', false],
- 'TRUE uppercase' => ['TRUE', true],
- 'FALSE uppercase' => ['FALSE', false],
- 'Yes uppercase' => ['Yes', true],
- 'No uppercase' => ['No', false],
- 'ON uppercase' => ['ON', true],
- 'OFF uppercase' => ['OFF', false],
- ];
- }
-
- public function testCastNonBooleanValue(): void
- {
- $input = 'not a boolean';
- $result = $this->booleanCaster->cast($input);
- $this->assertFalse($result);
- }
-
- /**
- * @dataProvider canCastProvider
- */
- public function testCanCast(mixed $input, bool $expected): void
- {
- $result = $this->booleanCaster->canCast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function canCastProvider(): array
- {
- return [
- 'true string' => ['true', true],
- 'false string' => ['false', true],
- '1 string' => ['1', true],
- '0 string' => ['0', true],
- 'yes string' => ['yes', true],
- 'no string' => ['no', true],
- 'on string' => ['on', true],
- 'off string' => ['off', true],
- 'TRUE uppercase' => ['TRUE', true],
- 'FALSE uppercase' => ['FALSE', true],
- 'non-boolean string' => ['not a boolean', false],
- 'integer' => ['42', false],
- 'array' => ['[]', false],
- ];
- }
-}
diff --git a/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php b/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php
deleted file mode 100644
index 967b1e0..0000000
--- a/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php
+++ /dev/null
@@ -1,131 +0,0 @@
-registry = new DotenvTypeCasterRegistry();
- }
-
- public function testRegisterAndCast(): void
- {
- $mockCaster = $this->createMock(TypeCaster::class);
- $mockCaster->expects($this->once())
- ->method('cast')
- ->with('input')
- ->willReturn('casted');
-
- $this->registry->register('test_type', $mockCaster);
-
- $result = $this->registry->cast('test_type', 'input');
- $this->assertSame('casted', $result);
- }
-
- public function testCastWithUnregisteredType(): void
- {
- $this->expectException(\OutOfRangeException::class);
- $this->expectExceptionMessage('Key not found: unregistered_type');
-
- $this->registry->cast('unregistered_type', 'input');
- }
-
- public function testCastWithVariousTypes(): void
- {
- $testCases = [
- 'registered_type' => ['string', 42, '42'],
- 'null_type' => ['null', 'null', null],
- ];
-
- foreach ($testCases as $type => [$castType, $input, $expected]) {
- $result = $this->registry->cast($castType, $input);
- $this->assertEquals($expected, $result, "Casting '{$input}' as '{$castType}' should return '{$expected}'");
- }
-
- // Test for unregistered type
- $this->expectException(\OutOfRangeException::class);
- $this->registry->cast('unregistered', 'value');
- }
-
- public function testDefaultCasters(): void
- {
- $testCases = [
- 'array' => ['[1,2,3]', [1, 2, 3]],
- 'json' => ['{"key":"value"}', ['key' => 'value']],
- 'null' => ['null', null],
- 'boolean' => ['true', true],
- 'integer' => ['42', 42],
- 'float' => ['3.14', 3.14],
- 'string' => [42, '42'],
- ];
-
- foreach ($testCases as $type => [$input, $expected]) {
- $result = $this->registry->cast($type, $input);
- $this->assertEquals($expected, $result, "Default caster for '{$type}' should modify the input correctly");
- }
- }
-
- public function testOverrideDefaultCaster(): void
- {
- $mockCaster = $this->createMock(TypeCaster::class);
- $mockCaster->expects($this->once())
- ->method('cast')
- ->with('input')
- ->willReturn('custom_casted');
-
- $this->registry->register('string', $mockCaster);
-
- $result = $this->registry->cast('string', 'input');
- $this->assertSame('custom_casted', $result);
- }
-
- public function testFallbackWhenGetReturnsNonTypeCaster(): void
- {
- $mockArrayList = $this->createMock(ArrayList::class);
- $mockArrayList->method('get')
- ->willReturn(new \stdClass()); // Retorna um objeto que não é TypeCaster
-
- $reflectionProperty = new \ReflectionProperty(DotenvTypeCasterRegistry::class, 'casters');
- $reflectionProperty->setAccessible(true);
- $reflectionProperty->setValue($this->registry, $mockArrayList);
-
- $input = 'test_value';
- $result = $this->registry->cast('any_type', $input);
-
- $this->assertSame($input, $result, 'Should return original value when get() returns non-TypeCaster');
- }
-
- public function testFallbackWhenGetReturnsNull(): void
- {
- $mockArrayList = $this->createMock(ArrayList::class);
- $mockArrayList->method('get')
- ->willReturn(null);
-
- $reflectionProperty = new \ReflectionProperty(DotenvTypeCasterRegistry::class, 'casters');
- $reflectionProperty->setAccessible(true);
- $reflectionProperty->setValue($this->registry, $mockArrayList);
-
- $input = 'test_value';
- $result = $this->registry->cast('any_type', $input);
-
- $this->assertSame($input, $result, 'Should return original value when get() returns null');
- }
-
- public function testRegisterNonTypeCompliantCaster(): void
- {
- $this->expectException(\TypeError::class);
- $this->registry->register('non_compliant', new \stdClass());
- }
-}
diff --git a/tests/Unit/Type/Caster/FloatCasterTest.php b/tests/Unit/Type/Caster/FloatCasterTest.php
deleted file mode 100644
index 6253049..0000000
--- a/tests/Unit/Type/Caster/FloatCasterTest.php
+++ /dev/null
@@ -1,71 +0,0 @@
-floatCaster = new FloatCaster();
- }
-
- /**
- * @dataProvider floatValuesProvider
- */
- public function testCast(mixed $input, float $expected): void
- {
- $result = $this->floatCaster->cast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function floatValuesProvider(): array
- {
- return [
- 'positive float string' => ['3.14', 3.14],
- 'negative float string' => ['-2.5', -2.5],
- 'zero float string' => ['0.0', 0.0],
- 'float with leading zero' => ['0.5', 0.5],
- 'float with trailing zero' => ['1.0', 1.0],
- 'scientific notation' => ['1.23e-4', 1.23e-4],
- 'float value' => [3.14, 3.14],
- 'integer value' => [42, 42.0],
- ];
- }
-
- public function testCastNonNumericValue(): void
- {
- $input = 'not a float';
- $result = $this->floatCaster->cast($input);
- $this->assertSame(0.0, $result);
- }
-
- /**
- * @dataProvider canCastProvider
- */
- public function testCanCast(mixed $input, bool $expected): void
- {
- $result = $this->floatCaster->canCast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function canCastProvider(): array
- {
- return [
- 'float string' => ['3.14', true],
- 'integer string' => ['42', false],
- 'non-numeric string' => ['not a float', false],
- 'float value' => [3.14, true],
- 'integer value' => [42, false],
- 'boolean value' => [true, false],
- 'array' => [[], false],
- ];
- }
-}
diff --git a/tests/Unit/Type/Caster/IntegerCasterTest.php b/tests/Unit/Type/Caster/IntegerCasterTest.php
deleted file mode 100644
index 751738a..0000000
--- a/tests/Unit/Type/Caster/IntegerCasterTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-integerCaster = new IntegerCaster();
- }
-
- /**
- * @dataProvider integerValuesProvider
- */
- public function testCast(mixed $input, int $expected): void
- {
- $result = $this->integerCaster->cast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function integerValuesProvider(): array
- {
- return [
- 'positive integer string' => ['42', 42],
- 'negative integer string' => ['-10', -10],
- 'zero string' => ['0', 0],
- 'integer with leading zeros' => ['007', 7],
- 'integer value' => [42, 42],
- 'float value' => [3.14, 3],
- ];
- }
-
- public function testCastNonNumericValue(): void
- {
- $input = 'not an integer';
- $result = $this->integerCaster->cast($input);
- $this->assertSame(0, $result);
- }
-
- /**
- * @dataProvider canCastProvider
- */
- public function testCanCast(mixed $input, bool $expected): void
- {
- $result = $this->integerCaster->canCast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function canCastProvider(): array
- {
- return [
- 'integer string' => ['42', true],
- 'float string' => ['3.14', false],
- 'non-numeric string' => ['not an integer', false],
- 'integer value' => [42, true],
- 'float value' => [3.14, false],
- 'boolean value' => [true, false],
- 'array' => [[], false],
- ];
- }
-}
diff --git a/tests/Unit/Type/Caster/JsonCasterTest.php b/tests/Unit/Type/Caster/JsonCasterTest.php
deleted file mode 100644
index 63aec72..0000000
--- a/tests/Unit/Type/Caster/JsonCasterTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-jsonCaster = new JsonCaster();
- }
-
- /**
- * @dataProvider jsonValuesProvider
- */
- public function testCast(string $input, mixed $expected): void
- {
- $result = $this->jsonCaster->cast($input);
- $this->assertEquals($expected, $result);
- }
-
- public static function jsonValuesProvider(): array
- {
- return [
- 'simple object' => ['{"key":"value"}', ['key' => 'value']],
- 'nested object' => ['{"outer":{"inner":"value"}}', ['outer' => ['inner' => 'value']]],
- 'array' => ['[1,2,3]', [1, 2, 3]],
- 'array of objects' => ['[{"id":1},{"id":2}]', [['id' => 1], ['id' => 2]]],
- 'string' => ['"hello"', 'hello'],
- 'number' => ['42', 42],
- 'boolean' => ['true', true],
- 'null' => ['null', null],
- ];
- }
-
- public function testCastInvalidJson(): void
- {
- $input = 'not a json';
- $result = $this->jsonCaster->cast($input);
- $this->assertSame($input, $result);
- }
-
- public function testCastNonStringValue(): void
- {
- $input = ['already', 'an', 'array'];
- $result = $this->jsonCaster->cast($input);
- $this->assertSame($input, $result);
- }
-
- public function testCastWithSurroundingQuotes(): void
- {
- $input = '"{"key":"value"}"';
- $expected = ['key' => 'value'];
- $result = $this->jsonCaster->cast($input);
- $this->assertEquals($expected, $result);
- }
-}
diff --git a/tests/Unit/Type/Caster/NullCasterTest.php b/tests/Unit/Type/Caster/NullCasterTest.php
deleted file mode 100644
index 634a193..0000000
--- a/tests/Unit/Type/Caster/NullCasterTest.php
+++ /dev/null
@@ -1,67 +0,0 @@
-nullCaster = new NullCaster();
- }
-
- /**
- * @dataProvider nullValuesProvider
- */
- public function testCast(mixed $input): void
- {
- $result = $this->nullCaster->cast($input);
- $this->assertNull($result);
- }
-
- public static function nullValuesProvider(): array
- {
- return [
- 'null string' => ['null'],
- 'empty string' => [''],
- 'NULL uppercase' => ['NULL'],
- 'null value' => [null],
- ];
- }
-
- /**
- * @dataProvider canCastProvider
- */
- public function testCanCast(mixed $input, bool $expected): void
- {
- $result = $this->nullCaster->canCast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function canCastProvider(): array
- {
- return [
- 'null string' => ['null', true],
- 'empty string' => ['', true],
- 'NULL uppercase' => ['NULL', false],
- 'non-null string' => ['not null', false],
- 'integer' => [0, false],
- 'boolean' => [false, false],
- 'array' => [[], false],
- ];
- }
-
- public function testCastNonNullValue(): void
- {
- $input = 'not null';
- $result = $this->nullCaster->cast($input);
- $this->assertNull($result);
- }
-}
diff --git a/tests/Unit/Type/Caster/StringCasterTest.php b/tests/Unit/Type/Caster/StringCasterTest.php
deleted file mode 100644
index 0a533a4..0000000
--- a/tests/Unit/Type/Caster/StringCasterTest.php
+++ /dev/null
@@ -1,79 +0,0 @@
-stringCaster = new StringCaster();
- }
-
- /**
- * @dataProvider stringValuesProvider
- */
- public function testCast(mixed $input, string $expected): void
- {
- $result = $this->stringCaster->cast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function stringValuesProvider(): array
- {
- return [
- 'simple string' => ['hello', 'hello'],
- 'integer' => [42, '42'],
- 'float' => [3.14, '3.14'],
- 'boolean true' => [true, '1'],
- 'boolean false' => [false, ''],
- 'null' => [null, ''],
- 'object with __toString' => [new class {
- public function __toString()
- {
- return 'object string';
- }
- }, 'object string'],
- ];
- }
-
- public function testCastObjectWithoutToString(): void
- {
- $this->expectException(\Error::class);
- $this->stringCaster->cast(new \stdClass());
- }
-
- /**
- * @dataProvider canCastProvider
- */
- public function testCanCast(mixed $input, bool $expected): void
- {
- $result = $this->stringCaster->canCast($input);
- $this->assertSame($expected, $result);
- }
-
- public static function canCastProvider(): array
- {
- return [
- 'string' => ['hello', true],
- 'integer' => [42, true],
- 'float' => [3.14, true],
- 'boolean' => [true, true],
- 'null' => [null, true],
- 'object with __toString' => [new class {
- public function __toString()
- {
- return 'object string';
- }
- }, true],
- 'object without __toString' => [new \stdClass(), false],
- ];
- }
-}
diff --git a/tests/Unit/Type/Detector/ArrayDetectorTest.php b/tests/Unit/Type/Detector/ArrayDetectorTest.php
deleted file mode 100644
index fb3ff2e..0000000
--- a/tests/Unit/Type/Detector/ArrayDetectorTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-detector = new ArrayDetector();
- }
-
- /**
- * @dataProvider validArrayProvider
- */
- public function testDetectValidArray(string $input): void
- {
- $this->assertSame('array', $this->detector->detect($input));
- }
-
- public static function validArrayProvider(): array
- {
- return [
- 'simple array' => ['[1, 2, 3]'],
- 'nested array' => ['[1, [2, 3], 4]'],
- 'array with strings' => ['["a", "b", "c"]'],
- 'array with mixed types' => ['[1, "two", 3.0, true]'],
- 'array with spaces' => ['[ 1, 2, 3 ]'],
- 'empty array' => ['[]'],
- ];
- }
-
- /**
- * @dataProvider invalidArrayProvider
- */
- public function testDetectInvalidArray($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidArrayProvider(): array
- {
- return [
- 'string' => ['not an array'],
- 'number' => [42],
- 'boolean' => [true],
- 'null' => [null],
- 'object notation' => ['{"key": "value"}'],
- 'unclosed bracket' => ['[1, 2, 3'],
- 'no brackets' => ['1, 2, 3'],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(100, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/BooleanDetectorTest.php b/tests/Unit/Type/Detector/BooleanDetectorTest.php
deleted file mode 100644
index dd2784e..0000000
--- a/tests/Unit/Type/Detector/BooleanDetectorTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-detector = new BooleanDetector();
- }
-
- /**
- * @dataProvider validBooleanProvider
- */
- public function testDetectValidBoolean($input): void
- {
- $this->assertSame('boolean', $this->detector->detect($input));
- }
-
- public static function validBooleanProvider(): array
- {
- return [
- 'true' => [true],
- 'false' => [false],
- 'string true' => ['true'],
- 'string false' => ['false'],
- 'string 1' => ['1'],
- 'string 0' => ['0'],
- 'string yes' => ['yes'],
- 'string no' => ['no'],
- 'string on' => ['on'],
- 'string off' => ['off'],
- 'uppercase TRUE' => ['TRUE'],
- 'uppercase FALSE' => ['FALSE'],
- ];
- }
-
- /**
- * @dataProvider invalidBooleanProvider
- */
- public function testDetectInvalidBoolean($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidBooleanProvider(): array
- {
- return [
- 'string' => ['not a boolean'],
- 'number' => [42],
- 'array' => [[1, 2, 3]],
- 'null' => [null],
- 'object' => [new \stdClass()],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(80, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php b/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php
deleted file mode 100644
index 031ec1c..0000000
--- a/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php
+++ /dev/null
@@ -1,122 +0,0 @@
-registry = new DotenvTypeDetectorRegistry();
- }
-
- public function testRegisterDetector(): void
- {
- $mockDetector = $this->createMock(TypeDetector::class);
- $mockDetector->method('getPriority')->willReturn(100);
- $mockDetector->method('detect')->willReturn('mock_type');
-
- $this->registry->registerDetector($mockDetector);
-
- $result = $this->registry->detectType('test_value');
- $this->assertSame('mock_type', $result);
- }
-
- public function testDetectTypeWithMultipleDetectors(): void
- {
- $mockDetector1 = $this->createMock(TypeDetector::class);
- $mockDetector1->method('getPriority')->willReturn(50);
- $mockDetector1->method('detect')->willReturn(null);
-
- $mockDetector2 = $this->createMock(TypeDetector::class);
- $mockDetector2->method('getPriority')->willReturn(100);
- $mockDetector2->method('detect')->willReturn('high_priority_type');
-
- $mockDetector3 = $this->createMock(TypeDetector::class);
- $mockDetector3->method('getPriority')->willReturn(75);
- $mockDetector3->method('detect')->willReturn('medium_priority_type');
-
- $this->registry->registerDetector($mockDetector1);
- $this->registry->registerDetector($mockDetector2);
- $this->registry->registerDetector($mockDetector3);
-
- $result = $this->registry->detectType('test_value');
- $this->assertSame('high_priority_type', $result);
- }
-
- public function testDetectTypeWithNoMatchingDetectors(): void
- {
- $mockDetector = $this->createMock(TypeDetector::class);
- $mockDetector->method('getPriority')->willReturn(100);
- $mockDetector->method('detect')->willReturn(null);
-
- $this->registry->registerDetector($mockDetector);
-
- $result = $this->registry->detectType('test_value');
- $this->assertSame('string', $result, "Should fallback to 'string' when no detector matches");
- }
-
- public function testFallbackToStringForUnrecognizedTypes(): void
- {
- // Remove all default detectors
- $reflectionProperty = new \ReflectionProperty(DotenvTypeDetectorRegistry::class, 'detectors');
- $reflectionProperty->setAccessible(true);
- $reflectionProperty->setValue($this->registry, new ArrayList());
-
- $unrecognizedValues = [
- 'complex_value' => new \stdClass(),
- 'resource' => fopen('php://memory', 'r'),
- 'closure' => function () {},
- ];
-
- foreach ($unrecognizedValues as $description => $value) {
- $result = $this->registry->detectType($value);
- $this->assertSame('string', $result, "Should fallback to 'string' for $description");
- }
- }
-
- public function testDefaultDetectors(): void
- {
- $testCases = [
- 'array' => '[1,2,3]',
- 'json' => '{"key":"value"}',
- 'null' => 'null',
- 'boolean' => 'true',
- 'integer' => '42',
- 'float' => '3.14',
- 'string' => 'hello world',
- ];
-
- foreach ($testCases as $expectedType => $value) {
- $detectedType = $this->registry->detectType($value);
- $this->assertSame($expectedType, $detectedType, "Failed to detect {$expectedType} for value: {$value}");
- }
- }
-
- public function testDetectorPrioritization(): void
- {
- $lowPriorityDetector = $this->createMock(TypeDetector::class);
- $lowPriorityDetector->method('getPriority')->willReturn(50);
- $lowPriorityDetector->method('detect')->willReturn('low_priority_type');
-
- $highPriorityDetector = $this->createMock(TypeDetector::class);
- $highPriorityDetector->method('getPriority')->willReturn(150);
- $highPriorityDetector->method('detect')->willReturn('high_priority_type');
-
- $this->registry->registerDetector($lowPriorityDetector);
- $this->registry->registerDetector($highPriorityDetector);
-
- $result = $this->registry->detectType('test_value');
- $this->assertSame('high_priority_type', $result);
- }
-}
diff --git a/tests/Unit/Type/Detector/JsonDetectorTest.php b/tests/Unit/Type/Detector/JsonDetectorTest.php
deleted file mode 100644
index e381382..0000000
--- a/tests/Unit/Type/Detector/JsonDetectorTest.php
+++ /dev/null
@@ -1,96 +0,0 @@
-detector = new JsonDetector();
- }
-
- /**
- * @dataProvider validJsonProvider
- */
- public function testDetectValidJson(string $input): void
- {
- $this->assertSame('json', $this->detector->detect($input));
- }
-
- public static function validJsonProvider(): array
- {
- return [
- 'simple object' => ['{"key": "value"}'],
- 'nested object' => ['{"outer": {"inner": "value"}}'],
- 'array of objects' => ['[{"id": 1}, {"id": 2}]'],
- 'complex json' => ['{"name": "John", "age": 30, "city": "New York", "hobbies": ["reading", "cycling"]}'],
- 'empty object' => ['{}'],
- 'empty array' => ['[]'],
- ];
- }
-
- /**
- * @dataProvider invalidJsonProvider
- */
- public function testDetectInvalidJson($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidJsonProvider(): array
- {
- return [
- 'string' => ['not a json'],
- 'number' => [42],
- 'boolean' => [true],
- 'null' => [null],
- 'array' => [[1, 2, 3]],
- 'invalid json string' => ['{key: "value"}'],
- 'incomplete json' => ['{"key": "value"'],
- ];
- }
-
- /**
- * @dataProvider jsonArrayOfObjectsProvider
- */
- public function testIsJsonArrayOfObjects(string $input, bool $expected): void
- {
- $reflectionMethod = new \ReflectionMethod(JsonDetector::class, 'isJsonArrayOfObjects');
- $reflectionMethod->setAccessible(true);
-
- $this->assertSame($expected, $reflectionMethod->invoke($this->detector, $input));
- }
-
- public static function jsonArrayOfObjectsProvider(): array
- {
- return [
- 'valid array of objects' => ['[{"id": 1}, {"id": 2}]', true],
- 'empty array' => ['[]', true],
- 'array with single object' => ['[{"id": 1}]', true],
- 'array of mixed types' => ['[{"id": 1}, 2, "string"]', false],
- 'array of arrays' => ['[[1, 2], [3, 4]]', false],
- 'simple array' => ['[1, 2, 3]', false],
- 'object, not array' => ['{"key": "value"}', false],
- 'invalid json' => ['not json', false],
- 'malformed json array' => ['[{"id": 1}, {"id": 2]', false],
- 'json number' => ['42', false],
- 'json string' => ['"string"', false],
- 'json true' => ['true', false],
- 'json false' => ['false', false],
- 'json null' => ['null', false],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(90, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/NullDetectorTest.php b/tests/Unit/Type/Detector/NullDetectorTest.php
deleted file mode 100644
index 8378576..0000000
--- a/tests/Unit/Type/Detector/NullDetectorTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
-detector = new NullDetector();
- }
-
- /**
- * @dataProvider validNullProvider
- */
- public function testDetectValidNull($input): void
- {
- $this->assertSame('null', $this->detector->detect($input));
- }
-
- public static function validNullProvider(): array
- {
- return [
- 'null value' => [null],
- 'null string' => ['null'],
- 'empty string' => [''],
- ];
- }
-
- /**
- * @dataProvider invalidNullProvider
- */
- public function testDetectInvalidNull($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidNullProvider(): array
- {
- return [
- 'string' => ['not null'],
- 'number' => ['42'],
- 'boolean' => ['true'],
- 'array' => ['[1, 2, 3]'],
- 'object' => ['{}'],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(95, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/NumericDetectorTest.php b/tests/Unit/Type/Detector/NumericDetectorTest.php
deleted file mode 100644
index 3f74ecb..0000000
--- a/tests/Unit/Type/Detector/NumericDetectorTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-detector = new NumericDetector();
- }
-
- /**
- * @dataProvider validNumericProvider
- */
- public function testDetectValidNumeric($input, string $expected): void
- {
- $this->assertSame($expected, $this->detector->detect($input));
- }
-
- public static function validNumericProvider(): array
- {
- return [
- 'integer' => ['42', 'integer'],
- 'negative integer' => ['-42', 'integer'],
- 'zero' => ['0', 'integer'],
- 'float' => ['3.14', 'float'],
- 'negative float' => ['-3.14', 'float'],
- 'string integer' => ['42', 'integer'],
- 'string float' => ['3.14', 'float'],
- ];
- }
-
- /**
- * @dataProvider invalidNumericProvider
- */
- public function testDetectInvalidNumeric($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidNumericProvider(): array
- {
- return [
- 'string' => ['not a number'],
- 'boolean' => [true],
- 'null' => [null],
- 'array' => [[1, 2, 3]],
- 'object' => [new \stdClass()],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(70, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/StringDetectorTest.php b/tests/Unit/Type/Detector/StringDetectorTest.php
deleted file mode 100644
index 28ff44e..0000000
--- a/tests/Unit/Type/Detector/StringDetectorTest.php
+++ /dev/null
@@ -1,63 +0,0 @@
-detector = new StringDetector();
- }
-
- /**
- * @dataProvider validStringProvider
- */
- public function testDetectValidString(string $input): void
- {
- $this->assertSame('string', $this->detector->detect($input));
- }
-
- public static function validStringProvider(): array
- {
- return [
- 'simple string' => ['hello'],
- 'empty string' => [''],
- 'numeric string' => ['42'],
- 'special characters' => ['!@#$%^&*()'],
- 'multi-line string' => ["line1\nline2"],
- ];
- }
-
- /**
- * @dataProvider invalidStringProvider
- */
- public function testDetectInvalidString($input): void
- {
- $this->assertNull($this->detector->detect($input));
- }
-
- public static function invalidStringProvider(): array
- {
- return [
- 'integer' => [42],
- 'float' => [3.14],
- 'boolean' => [true],
- 'null' => [null],
- 'array' => [[1, 2, 3]],
- 'object' => [new \stdClass()],
- ];
- }
-
- public function testGetPriority(): void
- {
- $this->assertSame(10, $this->detector->getPriority());
- }
-}
diff --git a/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php b/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php
deleted file mode 100644
index 98474bf..0000000
--- a/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
-assertEquals($expected, $this->extractArrayElements($input));
- }
-
- public function testAllElementsMeet(): void
- {
- $elements = ['1', '2', '3'];
- $condition = function ($element) {
- return is_numeric($element);
- };
- $this->assertTrue($this->allElementsMeet($elements, $condition));
-
- $elements = ['1', 'two', '3'];
- $this->assertFalse($this->allElementsMeet($elements, $condition));
- }
-}
diff --git a/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php b/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php
deleted file mode 100644
index 1df88bd..0000000
--- a/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
-assertTrue($this->isStringInput('test'));
- $this->assertFalse($this->isStringInput(123));
- $this->assertFalse($this->isStringInput([]));
- }
-
- public function testRemoveWhitespace(): void
- {
- $this->assertEquals('test', $this->removeWhitespace(' test '));
- $this->assertEquals('test', $this->removeWhitespace("\ttest\n"));
- }
-
- public function testHasDelimiters(): void
- {
- $this->assertTrue($this->hasDelimiters('[test]', '[', ']'));
- $this->assertFalse($this->hasDelimiters('test', '[', ']'));
- $this->assertFalse($this->hasDelimiters('[test', '[', ']'));
- $this->assertFalse($this->hasDelimiters('test]', '[', ']'));
- }
-}
diff --git a/tests/Unit/Type/TypeSystemTest.php b/tests/Unit/Type/TypeSystemTest.php
new file mode 100644
index 0000000..9a4395b
--- /dev/null
+++ b/tests/Unit/Type/TypeSystemTest.php
@@ -0,0 +1,205 @@
+typeSystem = new TypeSystem();
+ }
+
+ // ── Null Detection ────────────────────────────────────────────────
+
+ public function testDetectsNull(): void
+ {
+ self::assertSame(ValueType::Null, $this->typeSystem->detect('null'));
+ self::assertSame(ValueType::Null, $this->typeSystem->detect('NULL'));
+ self::assertSame(ValueType::Null, $this->typeSystem->detect('(null)'));
+ }
+
+ public function testEmptyStringIsNotNull(): void
+ {
+ self::assertSame(ValueType::String, $this->typeSystem->detect(''));
+ self::assertSame('', $this->typeSystem->resolve(''));
+ }
+
+ public function testCastsNull(): void
+ {
+ self::assertNull($this->typeSystem->resolve('null'));
+ self::assertNull($this->typeSystem->resolve('NULL'));
+ self::assertNull($this->typeSystem->resolve('(null)'));
+ }
+
+ // ── Boolean Detection ─────────────────────────────────────────────
+
+ public function testDetectsBoolean(): void
+ {
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('true'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('false'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('TRUE'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('FALSE'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('yes'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('no'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('on'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('off'));
+ }
+
+ public function testDetectsBooleanMixedCase(): void
+ {
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('True'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('False'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('Yes'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('No'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('On'));
+ self::assertSame(ValueType::Boolean, $this->typeSystem->detect('Off'));
+ }
+
+ public function testCastsBooleanTrue(): void
+ {
+ self::assertTrue($this->typeSystem->resolve('true'));
+ self::assertTrue($this->typeSystem->resolve('TRUE'));
+ self::assertTrue($this->typeSystem->resolve('yes'));
+ self::assertTrue($this->typeSystem->resolve('on'));
+ self::assertTrue($this->typeSystem->resolve('(true)'));
+ }
+
+ public function testCastsBooleanFalse(): void
+ {
+ self::assertFalse($this->typeSystem->resolve('false'));
+ self::assertFalse($this->typeSystem->resolve('FALSE'));
+ self::assertFalse($this->typeSystem->resolve('no'));
+ self::assertFalse($this->typeSystem->resolve('off'));
+ }
+
+ // ── Integer Detection ─────────────────────────────────────────────
+
+ public function testDetectsInteger(): void
+ {
+ self::assertSame(ValueType::Integer, $this->typeSystem->detect('42'));
+ self::assertSame(ValueType::Integer, $this->typeSystem->detect('0'));
+ self::assertSame(ValueType::Integer, $this->typeSystem->detect('-10'));
+ self::assertSame(ValueType::Integer, $this->typeSystem->detect('+99'));
+ }
+
+ public function testCastsInteger(): void
+ {
+ self::assertSame(42, $this->typeSystem->resolve('42'));
+ self::assertSame(0, $this->typeSystem->resolve('0'));
+ self::assertSame(-10, $this->typeSystem->resolve('-10'));
+ }
+
+ // ── Float Detection ───────────────────────────────────────────────
+
+ public function testDetectsFloat(): void
+ {
+ self::assertSame(ValueType::Float, $this->typeSystem->detect('3.14'));
+ self::assertSame(ValueType::Float, $this->typeSystem->detect('-0.5'));
+ self::assertSame(ValueType::Float, $this->typeSystem->detect('1e10'));
+ self::assertSame(ValueType::Float, $this->typeSystem->detect('2.5E-3'));
+ }
+
+ public function testCastsFloat(): void
+ {
+ self::assertSame(3.14, $this->typeSystem->resolve('3.14'));
+ self::assertSame(-0.5, $this->typeSystem->resolve('-0.5'));
+ }
+
+ // ── JSON Detection ────────────────────────────────────────────────
+
+ public function testDetectsJson(): void
+ {
+ self::assertSame(ValueType::Json, $this->typeSystem->detect('{"key": "value"}'));
+ self::assertSame(ValueType::Json, $this->typeSystem->detect('{"nested": {"a": 1}}'));
+ }
+
+ public function testCastsJson(): void
+ {
+ $result = $this->typeSystem->resolve('{"key": "value", "num": 42}');
+
+ self::assertIsArray($result);
+ self::assertSame('value', $result['key']);
+ self::assertSame(42, $result['num']);
+ }
+
+ public function testInvalidJsonNotDetected(): void
+ {
+ self::assertSame(ValueType::String, $this->typeSystem->detect('{invalid json}'));
+ }
+
+ // ── Array Detection ───────────────────────────────────────────────
+
+ public function testDetectsArray(): void
+ {
+ self::assertSame(ValueType::Array, $this->typeSystem->detect('["a", "b", "c"]'));
+ self::assertSame(ValueType::Array, $this->typeSystem->detect('[1, 2, 3]'));
+ }
+
+ public function testCastsArray(): void
+ {
+ $result = $this->typeSystem->resolve('["item1", "item2"]');
+
+ self::assertIsArray($result);
+ self::assertSame(['item1', 'item2'], $result);
+ }
+
+ // ── String Fallback ───────────────────────────────────────────────
+
+ public function testFallsBackToString(): void
+ {
+ self::assertSame(ValueType::String, $this->typeSystem->detect('hello world'));
+ self::assertSame(ValueType::String, $this->typeSystem->detect('/usr/local/bin'));
+ self::assertSame(ValueType::String, $this->typeSystem->detect('https://kariricode.org'));
+ }
+
+ public function testStringValuesReturnedAsIs(): void
+ {
+ self::assertSame('hello world', $this->typeSystem->resolve('hello world'));
+ }
+
+ // ── Custom Detector/Caster ────────────────────────────────────────
+
+ public function testCustomDetectorIsRespected(): void
+ {
+ $customDetector = new class () implements TypeDetector {
+ public function priority(): int
+ {
+ return 999; // Highest priority
+ }
+
+ public function detect(string $value): ?ValueType
+ {
+ return str_starts_with($value, 'CUSTOM:') ? ValueType::String : null;
+ }
+ };
+
+ $this->typeSystem->addDetector($customDetector);
+
+ // Custom detector matches first, but returns String
+ self::assertSame(ValueType::String, $this->typeSystem->detect('CUSTOM:42'));
+ }
+
+ public function testCustomCasterOverridesDefault(): void
+ {
+ $customCaster = new class () implements TypeCaster {
+ public function cast(string $value): int
+ {
+ return (int) $value * 100;
+ }
+ };
+
+ $this->typeSystem->addCaster(ValueType::Integer, $customCaster);
+
+ self::assertSame(4200, $this->typeSystem->resolve('42'));
+ }
+}
diff --git a/tests/Unit/Validation/EnvironmentValidatorTest.php b/tests/Unit/Validation/EnvironmentValidatorTest.php
new file mode 100644
index 0000000..b127f7f
--- /dev/null
+++ b/tests/Unit/Validation/EnvironmentValidatorTest.php
@@ -0,0 +1,300 @@
+ $variables[$name] ?? null,
+ );
+ }
+
+ // ── Required ──────────────────────────────────────────────────────
+
+ public function testRequiredPassesWhenPresent(): void
+ {
+ $v = $this->makeValidator(['DB_HOST' => 'localhost']);
+ $v->required('DB_HOST')->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testRequiredFailsWhenMissing(): void
+ {
+ $v = $this->makeValidator([]);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('DB_HOST is required');
+
+ $v->required('DB_HOST')->assert();
+ }
+
+ public function testMultipleRequiredReportsAll(): void
+ {
+ $v = $this->makeValidator(['DB_HOST' => 'localhost']);
+
+ try {
+ $v->required('DB_HOST', 'DB_PORT', 'DB_NAME')->assert();
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ $this->assertCount(2, $e->errors());
+ $this->assertStringContainsString('DB_PORT', $e->getMessage());
+ $this->assertStringContainsString('DB_NAME', $e->getMessage());
+ }
+ }
+
+ // ── NotEmpty ──────────────────────────────────────────────────────
+
+ public function testNotEmptyPasses(): void
+ {
+ $v = $this->makeValidator(['DB_HOST' => 'localhost']);
+ $v->required('DB_HOST')->notEmpty()->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testNotEmptyFails(): void
+ {
+ $v = $this->makeValidator(['DB_HOST' => ' ']);
+
+ $this->expectException(ValidationException::class);
+
+ $v->required('DB_HOST')->notEmpty()->assert();
+ }
+
+ // ── IsInteger ─────────────────────────────────────────────────────
+
+ public function testIsIntegerPasses(): void
+ {
+ $v = $this->makeValidator(['DB_PORT' => '5432']);
+ $v->required('DB_PORT')->isInteger()->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testIsIntegerFailsOnFloat(): void
+ {
+ $v = $this->makeValidator(['DB_PORT' => '54.32']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be an integer');
+
+ $v->required('DB_PORT')->isInteger()->assert();
+ }
+
+ public function testIsIntegerAcceptsSignedNumbers(): void
+ {
+ $v = $this->makeValidator(['OFFSET' => '-10']);
+ $v->required('OFFSET')->isInteger()->assert();
+ $this->assertTrue(true);
+ }
+
+ // ── Between ───────────────────────────────────────────────────────
+
+ public function testBetweenPasses(): void
+ {
+ $v = $this->makeValidator(['PORT' => '8080']);
+ $v->required('PORT')->isInteger()->between(1, 65535)->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testBetweenFailsBelowMin(): void
+ {
+ $v = $this->makeValidator(['PORT' => '0']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('between 1 and 65535');
+
+ $v->required('PORT')->isInteger()->between(1, 65535)->assert();
+ }
+
+ public function testBetweenFailsAboveMax(): void
+ {
+ $v = $this->makeValidator(['PORT' => '70000']);
+
+ $this->expectException(ValidationException::class);
+
+ $v->required('PORT')->isInteger()->between(1, 65535)->assert();
+ }
+
+ // ── IsBoolean ─────────────────────────────────────────────────────
+
+ public function testIsBooleanPassesForAllVariants(): void
+ {
+ foreach (['true', 'false', '1', '0', 'yes', 'no', 'on', 'off', 'TRUE', 'False'] as $val) {
+ $v = $this->makeValidator(['DEBUG' => $val]);
+ $v->required('DEBUG')->isBoolean()->assert();
+ }
+
+ $this->assertTrue(true);
+ }
+
+ public function testIsBooleanFails(): void
+ {
+ $v = $this->makeValidator(['DEBUG' => 'maybe']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be a boolean');
+
+ $v->required('DEBUG')->isBoolean()->assert();
+ }
+
+ // ── AllowedValues ─────────────────────────────────────────────────
+
+ public function testAllowedValuesPasses(): void
+ {
+ $v = $this->makeValidator(['ENV' => 'production']);
+ $v->allowedValues('ENV', ['local', 'staging', 'production'])->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testAllowedValuesFails(): void
+ {
+ $v = $this->makeValidator(['ENV' => 'invalid']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be one of');
+
+ $v->allowedValues('ENV', ['local', 'staging', 'production'])->assert();
+ }
+
+ // ── MatchesRegex ──────────────────────────────────────────────────
+
+ public function testMatchesRegexPasses(): void
+ {
+ $v = $this->makeValidator(['KEY' => 'abcdef0123456789abcdef0123456789']);
+ $v->matchesRegex('KEY', '/\A[a-f0-9]{32}\z/')->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testMatchesRegexFails(): void
+ {
+ $v = $this->makeValidator(['KEY' => 'short']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must match pattern');
+
+ $v->matchesRegex('KEY', '/\A[a-f0-9]{32}\z/')->assert();
+ }
+
+ // ── URL ───────────────────────────────────────────────────────────
+
+ public function testUrlPasses(): void
+ {
+ $v = $this->makeValidator(['APP_URL' => 'https://example.com']);
+ $v->url('APP_URL')->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testUrlFails(): void
+ {
+ $v = $this->makeValidator(['APP_URL' => 'not-a-url']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be a valid URL');
+
+ $v->url('APP_URL')->assert();
+ }
+
+ // ── Email ─────────────────────────────────────────────────────────
+
+ public function testEmailPasses(): void
+ {
+ $v = $this->makeValidator(['ADMIN' => 'admin@example.com']);
+ $v->email('ADMIN')->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testEmailFails(): void
+ {
+ $v = $this->makeValidator(['ADMIN' => 'not-an-email']);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must be a valid email');
+
+ $v->email('ADMIN')->assert();
+ }
+
+ // ── Custom ────────────────────────────────────────────────────────
+
+ public function testCustomCallbackPasses(): void
+ {
+ $v = $this->makeValidator(['DSN' => 'pgsql:host=localhost']);
+ $v->custom('DSN', fn (string $v): bool => str_starts_with($v, 'pgsql:'))->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testCustomCallbackFails(): void
+ {
+ $v = $this->makeValidator(['DSN' => 'mysql:host=localhost']);
+
+ $this->expectException(ValidationException::class);
+
+ $v->custom('DSN', fn (string $v): bool => str_starts_with($v, 'pgsql:'), 'DSN must use pgsql driver.')->assert();
+ }
+
+ // ── IfPresent (conditional) ───────────────────────────────────────
+
+ public function testIfPresentSkipsMissingVariable(): void
+ {
+ $v = $this->makeValidator([]);
+ $v->ifPresent('REDIS_HOST')->notEmpty()->assert();
+ $this->assertTrue(true);
+ }
+
+ public function testIfPresentValidatesWhenPresent(): void
+ {
+ $v = $this->makeValidator(['REDIS_HOST' => '']);
+
+ $this->expectException(ValidationException::class);
+
+ $v->ifPresent('REDIS_HOST')->notEmpty()->assert();
+ }
+
+ // ── Batch Errors ──────────────────────────────────────────────────
+
+ public function testBatchCollectsAllErrors(): void
+ {
+ $v = $this->makeValidator([
+ 'PORT' => 'abc',
+ 'DEBUG' => 'maybe',
+ ]);
+
+ try {
+ $v->required('PORT', 'DEBUG', 'MISSING')
+ ->isInteger('PORT')
+ ->isBoolean('DEBUG')
+ ->assert();
+ $this->fail('Expected ValidationException');
+ } catch (ValidationException $e) {
+ // MISSING is required, PORT is not integer, DEBUG is not boolean
+ $this->assertCount(3, $e->errors());
+ }
+ }
+
+ // ── IsNumeric ─────────────────────────────────────────────────────
+
+ public function testIsNumericPasses(): void
+ {
+ foreach (['42', '3.14', '-0.5', '1e10'] as $val) {
+ $v = $this->makeValidator(['N' => $val]);
+ $v->required('N')->isNumeric()->assert();
+ }
+
+ $this->assertTrue(true);
+ }
+
+ public function testIsNumericFails(): void
+ {
+ $v = $this->makeValidator(['N' => 'abc']);
+
+ $this->expectException(ValidationException::class);
+
+ $v->required('N')->isNumeric()->assert();
+ }
+}
diff --git a/tests/Unit/ValueObject/DotenvConfigurationTest.php b/tests/Unit/ValueObject/DotenvConfigurationTest.php
new file mode 100644
index 0000000..3fd7565
--- /dev/null
+++ b/tests/Unit/ValueObject/DotenvConfigurationTest.php
@@ -0,0 +1,170 @@
+loadMode);
+ self::assertFalse($config->strictNames);
+ self::assertTrue($config->typeCasting);
+ self::assertTrue($config->populateEnv);
+ self::assertTrue($config->populateServer);
+ self::assertFalse($config->usePutenv);
+ self::assertNull($config->encryptionKey);
+ self::assertNull($config->cachePath);
+ self::assertSame([], $config->allowList);
+ self::assertSame([], $config->denyList);
+ self::assertNull($config->environmentName);
+ }
+
+ // ── Immutability ─────────────────────────────────────────────────
+
+ public function testIsReadonly(): void
+ {
+ $config = new DotenvConfiguration();
+
+ $reflection = new \ReflectionClass($config);
+ self::assertTrue($reflection->isReadOnly());
+ }
+
+ public function testIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(DotenvConfiguration::class);
+ self::assertTrue($reflection->isFinal());
+ }
+
+ // ── with* methods return new instances ───────────────────────────
+
+ public function testWithLoadModeReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withLoadMode(LoadMode::Overwrite);
+
+ self::assertNotSame($original, $modified);
+ self::assertSame(LoadMode::Immutable, $original->loadMode);
+ self::assertSame(LoadMode::Overwrite, $modified->loadMode);
+ // Other fields preserved
+ self::assertSame($original->strictNames, $modified->strictNames);
+ self::assertSame($original->typeCasting, $modified->typeCasting);
+ }
+
+ public function testWithStrictNamesReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withStrictNames(true);
+
+ self::assertNotSame($original, $modified);
+ self::assertFalse($original->strictNames);
+ self::assertTrue($modified->strictNames);
+ self::assertSame($original->loadMode, $modified->loadMode);
+ }
+
+ public function testWithTypeCastingReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withTypeCasting(false);
+
+ self::assertNotSame($original, $modified);
+ self::assertTrue($original->typeCasting);
+ self::assertFalse($modified->typeCasting);
+ }
+
+ public function testWithEncryptionKeyReturnsNewInstance(): void
+ {
+ $key = str_repeat('a', 64);
+ $original = new DotenvConfiguration();
+ $modified = $original->withEncryptionKey($key);
+
+ self::assertNotSame($original, $modified);
+ self::assertNull($original->encryptionKey);
+ self::assertSame($key, $modified->encryptionKey);
+ }
+
+ public function testWithCachePathReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withCachePath('/tmp/.env.cache.php');
+
+ self::assertNotSame($original, $modified);
+ self::assertNull($original->cachePath);
+ self::assertSame('/tmp/.env.cache.php', $modified->cachePath);
+ }
+
+ public function testWithAllowListReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withAllowList(['DB_*', 'APP_*']);
+
+ self::assertNotSame($original, $modified);
+ self::assertSame([], $original->allowList);
+ self::assertSame(['DB_*', 'APP_*'], $modified->allowList);
+ }
+
+ public function testWithDenyListReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withDenyList(['SECRET*']);
+
+ self::assertNotSame($original, $modified);
+ self::assertSame([], $original->denyList);
+ self::assertSame(['SECRET*'], $modified->denyList);
+ }
+
+ public function testWithEnvironmentNameReturnsNewInstance(): void
+ {
+ $original = new DotenvConfiguration();
+ $modified = $original->withEnvironmentName('production');
+
+ self::assertNotSame($original, $modified);
+ self::assertNull($original->environmentName);
+ self::assertSame('production', $modified->environmentName);
+ }
+
+ // ── Chaining preserves all fields ────────────────────────────────
+
+ public function testChainingPreservesAllFields(): void
+ {
+ $config = new DotenvConfiguration()
+ ->withLoadMode(LoadMode::Overwrite)
+ ->withStrictNames(true)
+ ->withTypeCasting(false)
+ ->withEnvironmentName('staging');
+
+ self::assertSame(LoadMode::Overwrite, $config->loadMode);
+ self::assertTrue($config->strictNames);
+ self::assertFalse($config->typeCasting);
+ self::assertSame('staging', $config->environmentName);
+ // Untouched defaults preserved
+ self::assertNull($config->encryptionKey);
+ self::assertSame([], $config->allowList);
+ }
+
+ // ── Constructor override ──────────────────────────────────────────
+
+ public function testConstructorOverridesDefaults(): void
+ {
+ $config = new DotenvConfiguration(
+ loadMode: LoadMode::SkipExisting,
+ strictNames: true,
+ typeCasting: false,
+ encryptionKey: 'abc',
+ );
+
+ self::assertSame(LoadMode::SkipExisting, $config->loadMode);
+ self::assertTrue($config->strictNames);
+ self::assertFalse($config->typeCasting);
+ self::assertSame('abc', $config->encryptionKey);
+ }
+}
diff --git a/tests/Unit/ValueObject/EnvironmentVariableTest.php b/tests/Unit/ValueObject/EnvironmentVariableTest.php
new file mode 100644
index 0000000..af446b8
--- /dev/null
+++ b/tests/Unit/ValueObject/EnvironmentVariableTest.php
@@ -0,0 +1,95 @@
+name);
+ self::assertSame('5432', $var->rawValue);
+ self::assertSame(ValueType::Integer, $var->type);
+ self::assertSame(5432, $var->value);
+ self::assertSame('.env.local', $var->source);
+ self::assertTrue($var->overridden);
+ }
+
+ public function testDefaultSourceAndOverridden(): void
+ {
+ $var = new EnvironmentVariable(
+ name: 'APP_NAME',
+ rawValue: 'KaririCode',
+ type: ValueType::String,
+ value: 'KaririCode',
+ );
+
+ self::assertSame('', $var->source);
+ self::assertFalse($var->overridden);
+ }
+
+ public function testIsReadonly(): void
+ {
+ $reflection = new \ReflectionClass(EnvironmentVariable::class);
+ self::assertTrue($reflection->isReadOnly());
+ }
+
+ public function testIsFinal(): void
+ {
+ $reflection = new \ReflectionClass(EnvironmentVariable::class);
+ self::assertTrue($reflection->isFinal());
+ }
+
+ public function testNullValueIsAllowed(): void
+ {
+ $var = new EnvironmentVariable(
+ name: 'OPTIONAL',
+ rawValue: 'null',
+ type: ValueType::Null,
+ value: null,
+ );
+
+ self::assertNull($var->value);
+ self::assertSame(ValueType::Null, $var->type);
+ }
+
+ public function testComplexTypedValues(): void
+ {
+ $array = ['a', 'b'];
+ $var = new EnvironmentVariable(
+ name: 'ITEMS',
+ rawValue: '["a","b"]',
+ type: ValueType::Array,
+ value: $array,
+ );
+
+ self::assertSame($array, $var->value);
+ self::assertSame(ValueType::Array, $var->type);
+ }
+
+ public function testBooleanValue(): void
+ {
+ $var = new EnvironmentVariable(
+ name: 'DEBUG',
+ rawValue: 'true',
+ type: ValueType::Boolean,
+ value: true,
+ );
+
+ self::assertTrue($var->value);
+ }
+}
diff --git a/tests/application.php b/tests/application.php
deleted file mode 100644
index d247976..0000000
--- a/tests/application.php
+++ /dev/null
@@ -1,87 +0,0 @@
-load();
-
- $variables = [
- 'KARIRI_APP_ENV' => 'string',
- 'KARIRI_APP_NAME' => 'string',
- 'KARIRI_PHP_VERSION' => 'double',
- 'KARIRI_PHP_PORT' => 'integer',
- 'KARIRI_APP_DEBUG' => 'boolean',
- 'KARIRI_APP_URL' => 'string',
- 'KARIRI_DB_CONNECTION' => 'string',
- 'KARIRI_DB_HOST' => 'string',
- 'KARIRI_DB_PORT' => 'integer',
- 'KARIRI_DB_DATABASE' => 'string',
- 'KARIRI_DB_USERNAME' => 'string',
- 'KARIRI_DB_PASSWORD' => 'string',
- 'KARIRI_CACHE_DRIVER' => 'string',
- 'KARIRI_SESSION_LIFETIME' => 'integer',
- 'KARIRI_MAIL_MAILER' => 'string',
- 'KARIRI_MAIL_HOST' => 'string',
- 'KARIRI_MAIL_PORT' => 'integer',
- 'KARIRI_MAIL_USERNAME' => 'NULL',
- 'KARIRI_MAIL_PASSWORD' => 'NULL',
- 'KARIRI_MAIL_ENCRYPTION' => 'NULL',
- 'KARIRI_MAIL_FROM_ADDRESS' => 'NULL',
- 'KARIRI_MAIL_FROM_NAME' => 'string',
- 'KARIRI_ARRAY_CONFIG' => 'array',
- 'KARIRI_JSON_CONFIG' => 'json',
- ];
-
- foreach ($variables as $key => $expectedType) {
- testEnvVariable($key, $expectedType);
- }
-
- // Test variable interpolation
- $appName = env('KARIRI_APP_NAME');
- $mailFromName = env('KARIRI_MAIL_FROM_NAME');
- echo $appName === $mailFromName
- ? "Variable interpolation for KARIRI_MAIL_FROM_NAME works correctly.\n"
- : "WARNING: Variable interpolation for KARIRI_MAIL_FROM_NAME failed.\n";
-} catch (DotenvException $e) {
- echo 'An error occurred: ' . $e->getMessage() . "\n";
-}
-
-function testEnvVariable(string $key, string $expectedType): void
-{
- $value = env($key);
- $actualType = determineActualType($value, $expectedType);
-
- outputTestResult($key, $value, $expectedType, $actualType);
-
- if ($actualType !== $expectedType) {
- echo "WARNING: Type mismatch for $key\n";
- }
-}
-
-function determineActualType(mixed $value, string $expectedType): string
-{
- if ('json' === $expectedType && (is_array($value) || is_object($value))) {
- return 'json';
- }
-
- return gettype($value);
-}
-
-function outputTestResult(string $key, mixed $value, string $expectedType, string $actualType): void
-{
- $displayValue = is_array($value) || is_object($value) ? json_encode($value) : var_export($value, true);
- echo sprintf(
- "%s: %s (Expected: %s, Actual: %s)\n",
- $key,
- $displayValue,
- $expectedType,
- $actualType
- );
-}