diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4924f69
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,68 @@
+name: CI
+
+on: [pull_request]
+
+jobs:
+ format:
+ name: Format
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run format check
+ run: |
+ docker run --rm -v $PWD:/app composer:2.7 sh -c \
+ "composer install --profile --ignore-platform-reqs && composer format:check"
+
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run static analysis
+ run: |
+ docker run --rm -v $PWD:/app composer:2.7 sh -c \
+ "composer install --profile --ignore-platform-reqs && composer analyze"
+
+ unit-tests:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Run unit tests
+ run: |
+ docker run --rm -v $PWD:/app composer:2.7 sh -c \
+ "composer install --profile --ignore-platform-reqs && composer test -- --testsuite unit"
+
+ e2e-tests:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: false
+ tags: smtp-dev
+ load: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Start SMTP Server
+ run: |
+ docker compose up -d
+ sleep 5
+
+ - name: Run E2E tests
+ run: docker compose exec -T smtp-server vendor/bin/phpunit --configuration phpunit.xml --testsuite e2e
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2b08168
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/vendor/
+/.idea/
+out.log
+.phpunit.result.cache
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..02b9a8a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+FROM composer:2.7 AS composer
+
+ARG TESTING=false
+ENV TESTING=$TESTING
+
+WORKDIR /usr/local/src/
+
+COPY composer.lock /usr/local/src/
+COPY composer.json /usr/local/src/
+
+RUN composer install --ignore-platform-reqs --optimize-autoloader \
+ --no-plugins --no-scripts --prefer-dist
+
+FROM appwrite/base:0.11.3 AS final
+
+LABEL maintainer="team@appwrite.io"
+
+WORKDIR /usr/src/code
+
+RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
+
+RUN echo "opcache.enable_cli=1" >> $PHP_INI_DIR/php.ini
+
+RUN echo "memory_limit=1024M" >> $PHP_INI_DIR/php.ini
+
+COPY --from=composer /usr/local/src/vendor /usr/src/code/vendor
+
+COPY ./tests /usr/src/code/tests
+COPY ./src /usr/src/code/src
+COPY ./phpunit.xml /usr/src/code/phpunit.xml
+COPY ./phpstan.neon /usr/src/code/phpstan.neon
+COPY ./pint.json /usr/src/code/pint.json
+
+CMD [ "tail", "-f", "/dev/null" ]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ac621e7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 utopia
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index be5046b..b590d10 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,117 @@
-# smtp
-Lite & fast micro PHP SMTP framework that is easy to learn.
+# Utopia SMTP
+
+[](https://github.com/utopia-php/smtp/actions/workflows/ci.yml)
+[](https://packagist.org/packages/utopia-php/smtp)
+
+Utopia SMTP is a modern PHP 8.3 toolkit for building SMTP servers and clients. It provides a fully-typed RFC 5321/5322 message encoder/decoder, pluggable handlers, transports, and telemetry hooks so you can receive and relay email with minimal effort.
+
+Although part of the [Utopia Framework](https://github.com/utopia-php/framework) family, the library is framework-agnostic and can be used in any PHP project.
+
+## Installation
+
+```bash
+composer require utopia-php/smtp
+```
+
+The library requires PHP 8.3+ with the `ext-sockets` extension. The Swoole adapter additionally needs the `ext-swoole` extension.
+
+## Quick start
+
+Create an SMTP server by wiring an adapter (TCP socket implementation) and a handler (how messages are accepted). The example below uses the native PHP socket adapter and the in-memory handler.
+
+```php
+setDebug(true);
+
+$server->start();
+```
+
+Implement the [`Utopia\SMTP\Handler`](src/SMTP/Handler.php) interface to accept messages from databases, queues, or other stores.
+
+## Handlers
+
+- `Memory`: stores accepted messages in memory for testing or simple workloads
+- `Proxy`: relays accepted messages to another SMTP server using the bundled client
+
+Handlers can be combined with any adapter. Implementing the `Handler` interface allows you to plug in custom logic while reusing protocol and telemetry tooling.
+
+## Adapters
+
+- `Native`: pure PHP TCP server based on `ext-sockets`
+- `Swoole`: non-blocking TCP server built on the Swoole runtime
+
+Adapters are responsible only for accepting TCP connections. They call back into the server with a `Connection` so your handler logic stays isolated.
+
+## SMTP client
+
+The bundled client can deliver messages to any SMTP server.
+
+```php
+send($message);
+```
+
+## Transports
+
+- `Socket`: sends messages through the bundled SMTP client (mirrors DNS resolver transports)
+
+```php
+use Utopia\SMTP\Message;
+use Utopia\SMTP\Message\Address;
+use Utopia\SMTP\Transport\Socket;
+
+$transport = new Socket('127.0.0.1', 2525);
+$transport->send(Message::create(
+ new Address('sender@example.test'),
+ new Address('inbox@example.test'),
+ 'Subject',
+ 'Body',
+));
+```
+
+## Telemetry
+
+`Server::setTelemetry()` accepts any adapter from [`utopia-php/telemetry`](https://github.com/utopia-php/telemetry). Counters (`smtp.sessions.total`, `smtp.messages.total`) and a histogram (`smtp.session.duration`) are emitted automatically.
+
+## Development
+
+- Install dependencies: `composer install`
+- Static analysis: `composer analyze`
+- Coding standards: `composer format:check` (use `composer format` to auto-fix)
+- Tests: `composer test`
+- Sample server for manual and E2E testing: `docker compose up`
+
+## License
+
+MIT
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..87f15fe
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,46 @@
+{
+ "name": "utopia-php/smtp",
+ "description": "Lite & fast micro PHP SMTP server abstraction that is **easy to use**.",
+ "type": "library",
+ "keywords": ["php", "framework", "upf", "utopia", "smtp", "email"],
+ "license": "MIT",
+ "minimum-stability": "stable",
+ "scripts": {
+ "format": "./vendor/bin/pint --config pint.json",
+ "format:check": "./vendor/bin/pint --test --config pint.json",
+ "analyze": "./vendor/bin/phpstan analyse --level max -c phpstan.neon src tests",
+ "test": "./vendor/bin/phpunit --configuration phpunit.xml"
+ },
+ "authors": [
+ {
+ "name": "Eldad Fux",
+ "email": "eldad@appwrite.io"
+ }
+ ],
+ "autoload": {
+ "psr-4": {"Utopia\\SMTP\\": "src/SMTP"}
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\Unit\\Utopia\\": "tests/unit/"
+ }
+ },
+ "require": {
+ "php": ">=8.3",
+ "utopia-php/span": "1.1.*",
+ "utopia-php/telemetry": "*",
+ "utopia-php/validators": "0.*"
+ },
+ "require-dev": {
+ "swoole/ide-helper": "5.1.8",
+ "phpunit/phpunit": "12.5.*",
+ "laravel/pint": "1.29.*",
+ "phpstan/phpstan": "2.0.*"
+ },
+ "config": {
+ "allow-plugins": {
+ "php-http/discovery": true,
+ "tbachert/spi": true
+ }
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..0e67a4e
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,3875 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "4f5fa85b09766f1508f74c66c9a6ef60",
+ "packages": [
+ {
+ "name": "brick/math",
+ "version": "0.14.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/brick/math.git",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.2",
+ "phpstan/phpstan": "2.1.22",
+ "phpunit/phpunit": "^11.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Arbitrary-precision arithmetic library",
+ "keywords": [
+ "Arbitrary-precision",
+ "BigInteger",
+ "BigRational",
+ "arithmetic",
+ "bigdecimal",
+ "bignum",
+ "bignumber",
+ "brick",
+ "decimal",
+ "integer",
+ "math",
+ "mathematics",
+ "rational"
+ ],
+ "support": {
+ "issues": "https://github.com/brick/math/issues",
+ "source": "https://github.com/brick/math/tree/0.14.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/BenMorel",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-10T14:33:43+00:00"
+ },
+ {
+ "name": "composer/semver",
+ "version": "3.4.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/semver.git",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "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.4"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-20T19:15:30+00:00"
+ },
+ {
+ "name": "google/protobuf",
+ "version": "v4.33.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/protocolbuffers/protobuf-php.git",
+ "reference": "84b008c23915ed94536737eae46f41ba3bccfe67"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/84b008c23915ed94536737eae46f41ba3bccfe67",
+ "reference": "84b008c23915ed94536737eae46f41ba3bccfe67",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": ">=10.5.62 <11.0.0"
+ },
+ "suggest": {
+ "ext-bcmath": "Need to support JSON deserialization"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Google\\Protobuf\\": "src/Google/Protobuf",
+ "GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "proto library for PHP",
+ "homepage": "https://developers.google.com/protocol-buffers/",
+ "keywords": [
+ "proto"
+ ],
+ "support": {
+ "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.33.6"
+ },
+ "time": "2026-03-18T17:32:05+00:00"
+ },
+ {
+ "name": "nyholm/psr7",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7.git",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "php-http/message-factory-implementation": "1.0",
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "http-interop/http-factory-tests": "^0.9",
+ "php-http/message-factory": "^1.0",
+ "php-http/psr7-integration-tests": "^1.0",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
+ "symfony/error-handler": "^4.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "A fast PHP7 implementation of PSR-7",
+ "homepage": "https://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7/issues",
+ "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-09-09T07:06:30+00:00"
+ },
+ {
+ "name": "nyholm/psr7-server",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7-server.git",
+ "reference": "4335801d851f554ca43fa6e7d2602141538854dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc",
+ "reference": "4335801d851f554ca43fa6e7d2602141538854dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "require-dev": {
+ "nyholm/nsa": "^1.1",
+ "nyholm/psr7": "^1.3",
+ "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7Server\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "Helper classes to handle PSR-7 server requests",
+ "homepage": "http://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7-server/issues",
+ "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2023-11-08T09:30:43+00:00"
+ },
+ {
+ "name": "open-telemetry/api",
+ "version": "1.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/api.git",
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be",
+ "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be",
+ "shasum": ""
+ },
+ "require": {
+ "open-telemetry/context": "^1.4",
+ "php": "^8.1",
+ "psr/log": "^1.1|^2.0|^3.0",
+ "symfony/polyfill-php82": "^1.26"
+ },
+ "conflict": {
+ "open-telemetry/sdk": "<=1.11"
+ },
+ "type": "library",
+ "extra": {
+ "spi": {
+ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [
+ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "1.8.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "Trace/functions.php"
+ ],
+ "psr-4": {
+ "OpenTelemetry\\API\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "API for OpenTelemetry PHP.",
+ "keywords": [
+ "Metrics",
+ "api",
+ "apm",
+ "logging",
+ "opentelemetry",
+ "otel",
+ "tracing"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2026-02-25T13:24:05+00:00"
+ },
+ {
+ "name": "open-telemetry/context",
+ "version": "1.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/context.git",
+ "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07",
+ "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1",
+ "symfony/polyfill-php82": "^1.26"
+ },
+ "suggest": {
+ "ext-ffi": "To allow context switching in Fibers"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "fiber/initialize_fiber_handler.php"
+ ],
+ "psr-4": {
+ "OpenTelemetry\\Context\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "Context implementation for OpenTelemetry PHP.",
+ "keywords": [
+ "Context",
+ "opentelemetry",
+ "otel"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2025-10-19T06:44:33+00:00"
+ },
+ {
+ "name": "open-telemetry/exporter-otlp",
+ "version": "1.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/exporter-otlp.git",
+ "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/283a0d66522f2adc6d8d7debfd7686be91c282be",
+ "reference": "283a0d66522f2adc6d8d7debfd7686be91c282be",
+ "shasum": ""
+ },
+ "require": {
+ "open-telemetry/api": "^1.0",
+ "open-telemetry/gen-otlp-protobuf": "^1.1",
+ "open-telemetry/sdk": "^1.0",
+ "php": "^8.1",
+ "php-http/discovery": "^1.14"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "_register.php"
+ ],
+ "psr-4": {
+ "OpenTelemetry\\Contrib\\Otlp\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "OTLP exporter for OpenTelemetry.",
+ "keywords": [
+ "Metrics",
+ "exporter",
+ "gRPC",
+ "http",
+ "opentelemetry",
+ "otel",
+ "otlp",
+ "tracing"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2026-02-05T09:44:52+00:00"
+ },
+ {
+ "name": "open-telemetry/gen-otlp-protobuf",
+ "version": "1.9.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git",
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9",
+ "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9",
+ "shasum": ""
+ },
+ "require": {
+ "google/protobuf": "^3.22 || ^4.0",
+ "php": "^8.0"
+ },
+ "suggest": {
+ "ext-protobuf": "For better performance, when dealing with the protobuf format"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Opentelemetry\\Proto\\": "Opentelemetry/Proto/",
+ "GPBMetadata\\Opentelemetry\\": "GPBMetadata/Opentelemetry/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "PHP protobuf files for communication with OpenTelemetry OTLP collectors/servers.",
+ "keywords": [
+ "Metrics",
+ "apm",
+ "gRPC",
+ "logging",
+ "opentelemetry",
+ "otel",
+ "otlp",
+ "protobuf",
+ "tracing"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2025-10-19T06:44:33+00:00"
+ },
+ {
+ "name": "open-telemetry/sdk",
+ "version": "1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/sdk.git",
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
+ "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "nyholm/psr7-server": "^1.1",
+ "open-telemetry/api": "^1.8",
+ "open-telemetry/context": "^1.4",
+ "open-telemetry/sem-conv": "^1.0",
+ "php": "^8.1",
+ "php-http/discovery": "^1.14",
+ "psr/http-client": "^1.0",
+ "psr/http-client-implementation": "^1.0",
+ "psr/http-factory-implementation": "^1.0",
+ "psr/http-message": "^1.0.1|^2.0",
+ "psr/log": "^1.1|^2.0|^3.0",
+ "ramsey/uuid": "^3.0 || ^4.0",
+ "symfony/polyfill-mbstring": "^1.23",
+ "symfony/polyfill-php82": "^1.26",
+ "tbachert/spi": "^1.0.5"
+ },
+ "suggest": {
+ "ext-gmp": "To support unlimited number of synchronous metric readers",
+ "ext-mbstring": "To increase performance of string operations",
+ "open-telemetry/sdk-configuration": "File-based OpenTelemetry SDK configuration"
+ },
+ "type": "library",
+ "extra": {
+ "spi": {
+ "OpenTelemetry\\API\\Configuration\\ConfigEnv\\EnvComponentLoader": [
+ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderHttpConfig",
+ "OpenTelemetry\\API\\Instrumentation\\Configuration\\General\\ConfigEnv\\EnvComponentLoaderPeerConfig"
+ ],
+ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\ResolverInterface": [
+ "OpenTelemetry\\SDK\\Common\\Configuration\\Resolver\\SdkConfigurationResolver"
+ ],
+ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\HookManagerInterface": [
+ "OpenTelemetry\\API\\Instrumentation\\AutoInstrumentation\\ExtensionHookManager"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "1.12.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "Common/Util/functions.php",
+ "Logs/Exporter/_register.php",
+ "Metrics/MetricExporter/_register.php",
+ "Propagation/_register.php",
+ "Trace/SpanExporter/_register.php",
+ "Common/Dev/Compatibility/_load.php",
+ "_autoload.php"
+ ],
+ "psr-4": {
+ "OpenTelemetry\\SDK\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "SDK for OpenTelemetry PHP.",
+ "keywords": [
+ "Metrics",
+ "apm",
+ "logging",
+ "opentelemetry",
+ "otel",
+ "sdk",
+ "tracing"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2026-03-21T11:50:01+00:00"
+ },
+ {
+ "name": "open-telemetry/sem-conv",
+ "version": "1.38.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opentelemetry-php/sem-conv.git",
+ "reference": "e613bc640a407def4991b8a936a9b27edd9a3240"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240",
+ "reference": "e613bc640a407def4991b8a936a9b27edd9a3240",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "OpenTelemetry\\SemConv\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "opentelemetry-php contributors",
+ "homepage": "https://github.com/open-telemetry/opentelemetry-php/graphs/contributors"
+ }
+ ],
+ "description": "Semantic conventions for OpenTelemetry PHP.",
+ "keywords": [
+ "Metrics",
+ "apm",
+ "logging",
+ "opentelemetry",
+ "otel",
+ "semantic conventions",
+ "semconv",
+ "tracing"
+ ],
+ "support": {
+ "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V",
+ "docs": "https://opentelemetry.io/docs/languages/php",
+ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
+ "source": "https://github.com/open-telemetry/opentelemetry-php"
+ },
+ "time": "2026-01-21T04:14:03+00:00"
+ },
+ {
+ "name": "php-http/discovery",
+ "version": "1.20.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-http/discovery.git",
+ "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
+ "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^1.0|^2.0",
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "nyholm/psr7": "<1.0",
+ "zendframework/zend-diactoros": "*"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "*",
+ "psr/http-factory-implementation": "*",
+ "psr/http-message-implementation": "*"
+ },
+ "require-dev": {
+ "composer/composer": "^1.0.2|^2.0",
+ "graham-campbell/phpspec-skip-example-extension": "^5.0",
+ "php-http/httplug": "^1.0 || ^2.0",
+ "php-http/message-factory": "^1.0",
+ "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
+ "sebastian/comparator": "^3.0.5 || ^4.0.8",
+ "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Http\\Discovery\\Composer\\Plugin",
+ "plugin-optional": true
+ },
+ "autoload": {
+ "psr-4": {
+ "Http\\Discovery\\": "src/"
+ },
+ "exclude-from-classmap": [
+ "src/Composer/Plugin.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com"
+ }
+ ],
+ "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
+ "homepage": "http://php-http.org",
+ "keywords": [
+ "adapter",
+ "client",
+ "discovery",
+ "factory",
+ "http",
+ "message",
+ "psr17",
+ "psr7"
+ ],
+ "support": {
+ "issues": "https://github.com/php-http/discovery/issues",
+ "source": "https://github.com/php-http/discovery/tree/1.20.0"
+ },
+ "time": "2024-10-02T11:20:13+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/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": "ramsey/collection",
+ "version": "2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ramsey/collection.git",
+ "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2",
+ "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "captainhook/plugin-composer": "^5.3",
+ "ergebnis/composer-normalize": "^2.45",
+ "fakerphp/faker": "^1.24",
+ "hamcrest/hamcrest-php": "^2.0",
+ "jangregor/phpstan-prophecy": "^2.1",
+ "mockery/mockery": "^1.6",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpspec/prophecy-phpunit": "^2.3",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-mockery": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^10.5",
+ "ramsey/coding-standard": "^2.3",
+ "ramsey/conventional-commits": "^1.6",
+ "roave/security-advisories": "dev-latest"
+ },
+ "type": "library",
+ "extra": {
+ "captainhook": {
+ "force-install": true
+ },
+ "ramsey/conventional-commits": {
+ "configFile": "conventional-commits.json"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Ramsey\\Collection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ben Ramsey",
+ "email": "ben@benramsey.com",
+ "homepage": "https://benramsey.com"
+ }
+ ],
+ "description": "A PHP library for representing and manipulating collections.",
+ "keywords": [
+ "array",
+ "collection",
+ "hash",
+ "map",
+ "queue",
+ "set"
+ ],
+ "support": {
+ "issues": "https://github.com/ramsey/collection/issues",
+ "source": "https://github.com/ramsey/collection/tree/2.1.1"
+ },
+ "time": "2025-03-22T05:38:12+00:00"
+ },
+ {
+ "name": "ramsey/uuid",
+ "version": "4.9.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ramsey/uuid.git",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
+ "php": "^8.0",
+ "ramsey/collection": "^1.2 || ^2.0"
+ },
+ "replace": {
+ "rhumsaa/uuid": "self.version"
+ },
+ "require-dev": {
+ "captainhook/captainhook": "^5.25",
+ "captainhook/plugin-composer": "^5.3",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "ergebnis/composer-normalize": "^2.47",
+ "mockery/mockery": "^1.6",
+ "paragonie/random-lib": "^2",
+ "php-mock/php-mock": "^2.6",
+ "php-mock/php-mock-mockery": "^1.5",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpbench/phpbench": "^1.2.14",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-mockery": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "slevomat/coding-standard": "^8.18",
+ "squizlabs/php_codesniffer": "^3.13"
+ },
+ "suggest": {
+ "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
+ "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
+ "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
+ "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
+ "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
+ },
+ "type": "library",
+ "extra": {
+ "captainhook": {
+ "force-install": true
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Ramsey\\Uuid\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
+ "keywords": [
+ "guid",
+ "identifier",
+ "uuid"
+ ],
+ "support": {
+ "issues": "https://github.com/ramsey/uuid/issues",
+ "source": "https://github.com/ramsey/uuid/tree/4.9.2"
+ },
+ "time": "2025-12-14T04:43:48+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "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.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-13T15:52:40+00:00"
+ },
+ {
+ "name": "symfony/http-client",
+ "version": "v7.4.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client.git",
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/http-client-contracts": "~3.4.4|^3.5.2",
+ "symfony/polyfill-php83": "^1.29",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "amphp/amp": "<2.5",
+ "amphp/socket": "<1.1",
+ "php-http/discovery": "<1.15",
+ "symfony/http-foundation": "<6.4"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "1.0",
+ "symfony/http-client-implementation": "3.0"
+ },
+ "require-dev": {
+ "amphp/http-client": "^4.2.1|^5.0",
+ "amphp/http-tunnel": "^1.0|^2.0",
+ "guzzlehttp/promises": "^1.4|^2.0",
+ "nyholm/psr7": "^1.0",
+ "php-http/httplug": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "symfony/amphp-http-client-meta": "^1.0|^2.0",
+ "symfony/cache": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpClient\\": ""
+ },
+ "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 powerful methods to fetch HTTP resources synchronously or asynchronously",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client/tree/v7.4.9"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-29T13:25:15+00:00"
+ },
+ {
+ "name": "symfony/http-client-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client-contracts.git",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\HttpClient\\": ""
+ },
+ "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 HTTP clients",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-06T13:17:50+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "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.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T17:25:58+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php82",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php82.git",
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59",
+ "reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php82\\": ""
+ },
+ "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.2+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php83.git",
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php83\\": ""
+ },
+ "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.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T17:25:58+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "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": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "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.7.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-28T09:44:51+00:00"
+ },
+ {
+ "name": "tbachert/spi",
+ "version": "v1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nevay/spi.git",
+ "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nevay/spi/zipball/e7078767866d0a9e0f91d3f9d42a832df5e39002",
+ "reference": "e7078767866d0a9e0f91d3f9d42a832df5e39002",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0",
+ "composer/semver": "^1.0 || ^2.0 || ^3.0",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "composer/composer": "^2.0",
+ "infection/infection": "^0.27.9",
+ "phpunit/phpunit": "^10.5",
+ "psalm/phar": "^5.18"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Nevay\\SPI\\Composer\\Plugin",
+ "branch-alias": {
+ "dev-main": "1.0.x-dev"
+ },
+ "plugin-optional": true
+ },
+ "autoload": {
+ "psr-4": {
+ "Nevay\\SPI\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "description": "Service provider loading facility",
+ "keywords": [
+ "service provider"
+ ],
+ "support": {
+ "issues": "https://github.com/Nevay/spi/issues",
+ "source": "https://github.com/Nevay/spi/tree/v1.0.5"
+ },
+ "time": "2025-06-29T15:42:06+00:00"
+ },
+ {
+ "name": "utopia-php/span",
+ "version": "1.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/span.git",
+ "reference": "028406940ca92bdc88099f0b1a123a3b2cbdd4e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/span/zipball/028406940ca92bdc88099f0b1a123a3b2cbdd4e5",
+ "reference": "028406940ca92bdc88099f0b1a123a3b2cbdd4e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^10.0",
+ "rector/rector": "^2.3",
+ "swoole/ide-helper": "^5.0"
+ },
+ "suggest": {
+ "ext-swoole": "Required for coroutine-based storage"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Span\\": "src/Span/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Simple span tracing library for PHP with coroutine support",
+ "support": {
+ "issues": "https://github.com/utopia-php/span/issues",
+ "source": "https://github.com/utopia-php/span/tree/1.1.5"
+ },
+ "time": "2026-02-13T18:00:11+00:00"
+ },
+ {
+ "name": "utopia-php/telemetry",
+ "version": "0.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/telemetry.git",
+ "reference": "62bbadad03e593b071b8ca63fac2c117c1900991"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/telemetry/zipball/62bbadad03e593b071b8ca63fac2c117c1900991",
+ "reference": "62bbadad03e593b071b8ca63fac2c117c1900991",
+ "shasum": ""
+ },
+ "require": {
+ "ext-opentelemetry": "*",
+ "ext-protobuf": "*",
+ "nyholm/psr7": "1.*",
+ "open-telemetry/exporter-otlp": "1.*",
+ "open-telemetry/sdk": "1.*",
+ "php": ">=8.0",
+ "symfony/http-client": "7.*"
+ },
+ "require-dev": {
+ "laravel/pint": "1.*",
+ "phpbench/phpbench": "1.*",
+ "phpstan/phpstan": "2.*",
+ "phpunit/phpunit": "11.*",
+ "swoole/ide-helper": "6.*"
+ },
+ "suggest": {
+ "ext-sockets": "Required for the Swoole transport implementation",
+ "ext-swoole": "Required for the Swoole transport implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Telemetry\\": "src/Telemetry"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "keywords": [
+ "framework",
+ "php",
+ "upf"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/telemetry/issues",
+ "source": "https://github.com/utopia-php/telemetry/tree/0.3.0"
+ },
+ "time": "2026-04-01T13:52:56+00:00"
+ },
+ {
+ "name": "utopia-php/validators",
+ "version": "0.2.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/utopia-php/validators.git",
+ "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/utopia-php/validators/zipball/b4ee60db4dbae5ffbe53968d01f69b6941251576",
+ "reference": "b4ee60db4dbae5ffbe53968d01f69b6941251576",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "laravel/pint": "1.*",
+ "phpstan/phpstan": "2.*",
+ "phpunit/phpunit": "11.*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Utopia\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A lightweight collection of reusable validators for Utopia projects",
+ "keywords": [
+ "php",
+ "utopia",
+ "validation",
+ "validator"
+ ],
+ "support": {
+ "issues": "https://github.com/utopia-php/validators/issues",
+ "source": "https://github.com/utopia-php/validators/tree/0.2.4"
+ },
+ "time": "2026-05-21T12:47:43+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "laravel/pint",
+ "version": "v1.29.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2026-04-20T15:26:14+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "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.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "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.x-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.7.0"
+ },
+ "time": "2025-12-06T11:56:16+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": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpstan.git",
+ "reference": "50d276fc3bf1430ec315f2f109bbde2769821524"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/50d276fc3bf1430ec315f2f109bbde2769821524",
+ "reference": "50d276fc3bf1430ec315f2f109bbde2769821524",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^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-12-17T17:14:01+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "12.5.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/lines-of-code": "^4.0",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.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": "12.5.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/12.5.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-15T08:23:17+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "6.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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",
+ "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/6.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-02T14:04:18+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "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",
+ "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/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:58+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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": "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/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:59:16+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.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/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:59:38+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "12.5.26",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d",
+ "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.6",
+ "phpunit/php-file-iterator": "^6.0.1",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.1",
+ "sebastian/comparator": "^7.1.8",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.1.1",
+ "sebastian/exporter": "^7.0.3",
+ "sebastian/global-state": "^8.0.2",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.4",
+ "sebastian/version": "^6.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "12.5-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/12.5.26"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
+ }
+ ],
+ "time": "2026-05-21T12:36:53+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "4.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.2-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/4.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-17T05:29:34+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "7.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "7c65c1e79836812819705b473a90c12399542485"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485",
+ "reference": "7c65c1e79836812819705b473a90c12399542485",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.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/7.1.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-21T04:45:25+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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": "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/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:25+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
+ },
+ "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"
+ },
+ {
+ "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/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:46+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "8.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9",
+ "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.1-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/8.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-21T08:45:32+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "7.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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"
+ },
+ {
+ "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/7.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T04:37:17+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "8.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^12.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.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/8.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-29T11:29:25+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "4.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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 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/4.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-19T16:22:07+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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": "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/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:57:48+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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"
+ }
+ ],
+ "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/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:58:17+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "7.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.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"
+ },
+ {
+ "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/7.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-13T04:44:59+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "6.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.5.25"
+ },
+ "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",
+ "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/6.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-20T06:45:45+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "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",
+ "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/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T05:00:38+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "swoole/ide-helper",
+ "version": "5.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/swoole/ide-helper.git",
+ "reference": "2806169ec7385e3b5eb484efef1f4764af46d995"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/swoole/ide-helper/zipball/2806169ec7385e3b5eb484efef1f4764af46d995",
+ "reference": "2806169ec7385e3b5eb484efef1f4764af46d995",
+ "shasum": ""
+ },
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Team Swoole",
+ "email": "team@swoole.com"
+ }
+ ],
+ "description": "IDE help files for Swoole.",
+ "support": {
+ "issues": "https://github.com/swoole/ide-helper/issues",
+ "source": "https://github.com/swoole/ide-helper/tree/5.1.8"
+ },
+ "time": "2025-08-04T05:40:28+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "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/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.3"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..728f9b6
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ smtp-server:
+ image: smtp-dev
+ build:
+ context: .
+ command: [ "sh", "-c", "php /usr/src/code/tests/resources/server.php" ]
+ volumes:
+ - ./src:/usr/src/code/src
+ - ./tests:/usr/src/code/tests
+ - ./phpunit.xml:/usr/src/code/phpunit.xml
+ networks:
+ - smtp
+ ports:
+ - '2525:2525/tcp'
+networks:
+ smtp:
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..68ce85e
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,8 @@
+parameters:
+ scanDirectories:
+ - vendor/swoole/ide-helper
+ excludePaths:
+ - tests/resources
+ - tests/unit/SMTP/Connection/SwooleTest.php
+ - tests/unit/SMTP/Server/SwooleServerTest.php
+ - vendor
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..7b2ab44
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,8 @@
+
+
+ tests/unit
+
+
+ tests/e2e
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..6115c68
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,4 @@
+{
+ "preset": "psr12",
+ "exclude": ["tests/resources"]
+}
diff --git a/src/SMTP/Adapter.php b/src/SMTP/Adapter.php
new file mode 100644
index 0000000..b874996
--- /dev/null
+++ b/src/SMTP/Adapter.php
@@ -0,0 +1,32 @@
+ */
+ protected array $onWorkerStart = [];
+
+ /** @var callable(Connection $connection): void|null */
+ protected mixed $onConnection = null;
+
+ /** @var array */
+ protected array $clients = [];
+
+ public function __construct(
+ protected string $host = '0.0.0.0',
+ protected int $port = 2525,
+ protected int $maxClients = 100,
+ protected int $idleTimeout = 60,
+ ) {
+ $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (!$server instanceof Socket) {
+ throw new Exception('Could not start SMTP server.');
+ }
+
+ socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1);
+ $this->server = $server;
+ }
+
+ /**
+ * @param callable(int $workerId): void $callback
+ * @phpstan-param callable(int $workerId): void $callback
+ */
+ public function onWorkerStart(callable $callback): void
+ {
+ $this->onWorkerStart[] = $callback;
+ }
+
+ /**
+ * @param callable(Connection $connection): void $callback
+ * @phpstan-param callable(Connection $connection): void $callback
+ */
+ public function onConnection(callable $callback): void
+ {
+ $this->onConnection = $callback;
+ }
+
+ public function start(): void
+ {
+ if ($this->onConnection === null) {
+ throw new Exception('Connection handler not registered.');
+ }
+
+ if (socket_bind($this->server, $this->host, $this->port) === false) {
+ throw new Exception('Could not bind SMTP server.');
+ }
+
+ if (socket_listen($this->server, 128) === false) {
+ throw new Exception('Could not listen on SMTP server.');
+ }
+
+ socket_set_nonblock($this->server);
+
+ foreach ($this->onWorkerStart as $callback) {
+ \call_user_func($callback, 0);
+ }
+
+ $handler = $this->onConnection;
+
+ /** @phpstan-ignore-next-line */
+ while (true) {
+ $this->closeIdleClients();
+
+ $readSockets = [$this->server];
+ foreach ($this->clients as $client) {
+ $readSockets[] = $client;
+ }
+
+ $write = [];
+ $except = [];
+ $changed = socket_select($readSockets, $write, $except, 1);
+
+ if ($changed === false || $changed === 0) {
+ continue;
+ }
+
+ foreach ($readSockets as $socket) {
+ if ($socket === $this->server) {
+ $client = @socket_accept($this->server);
+
+ if ($client instanceof Socket) {
+ if (count($this->clients) >= $this->maxClients) {
+ @socket_close($client);
+ continue;
+ }
+
+ if (@socket_set_nonblock($client) === false) {
+ @socket_close($client);
+ continue;
+ }
+
+ socket_set_option($client, SOL_SOCKET, SO_KEEPALIVE, 1);
+ $id = spl_object_id($client);
+ $this->clients[$id] = $client;
+ $this->clientActivity[$id] = time();
+
+ $ip = '';
+ $port = 0;
+ socket_getpeername($client, $ip, $port);
+
+ if (!is_string($ip) || !is_int($port)) {
+ $this->closeClient($client);
+ continue;
+ }
+
+ $connection = new SocketConnection($client, $ip, $port);
+ \call_user_func($handler, $connection);
+ $this->closeClient($client);
+ }
+
+ continue;
+ }
+
+ $this->clientActivity[spl_object_id($socket)] = time();
+ }
+ }
+ }
+
+ public function getName(): string
+ {
+ return 'native';
+ }
+
+ /** @var array */
+ protected array $clientActivity = [];
+
+ protected function closeIdleClients(): void
+ {
+ $now = time();
+
+ foreach ($this->clients as $id => $client) {
+ $lastActivity = $this->clientActivity[$id] ?? 0;
+
+ if (($now - $lastActivity) > $this->idleTimeout) {
+ $this->closeClient($client);
+ }
+ }
+ }
+
+ protected function closeClient(Socket $client): void
+ {
+ $id = spl_object_id($client);
+ unset($this->clients[$id], $this->clientActivity[$id]);
+ @socket_close($client);
+ }
+}
diff --git a/src/SMTP/Adapter/Swoole.php b/src/SMTP/Adapter/Swoole.php
new file mode 100644
index 0000000..95d7de0
--- /dev/null
+++ b/src/SMTP/Adapter/Swoole.php
@@ -0,0 +1,98 @@
+ */
+ protected array $onWorkerStart = [];
+
+ /** @var callable(Connection $connection): void|null */
+ protected mixed $onConnection = null;
+
+ public function __construct(
+ protected string $host = '0.0.0.0',
+ protected int $port = 2525,
+ protected int $numWorkers = 1,
+ protected int $maxCoroutines = 3000,
+ ) {
+ $this->server = new Server($this->host, $this->port, SWOOLE_PROCESS, SWOOLE_SOCK_TCP);
+ $this->server->set([
+ 'worker_num' => $this->numWorkers,
+ 'max_coroutine' => $this->maxCoroutines,
+ 'enable_coroutine' => true,
+ ]);
+ }
+
+ /**
+ * @param callable(int $workerId): void $callback
+ * @phpstan-param callable(int $workerId): void $callback
+ */
+ public function onWorkerStart(callable $callback): void
+ {
+ $this->onWorkerStart[] = $callback;
+ }
+
+ /**
+ * @param callable(Connection $connection): void $callback
+ * @phpstan-param callable(Connection $connection): void $callback
+ */
+ public function onConnection(callable $callback): void
+ {
+ $this->onConnection = $callback;
+ }
+
+ public function start(): void
+ {
+ if ($this->onConnection === null) {
+ throw new Exception('Connection handler not registered.');
+ }
+
+ $handler = $this->onConnection;
+
+ $this->server->on('WorkerStart', function ($server, $workerId) {
+ if (!is_int($workerId)) {
+ return;
+ }
+
+ foreach ($this->onWorkerStart as $callback) {
+ \call_user_func($callback, $workerId);
+ }
+ });
+
+ $this->server->on('Connect', function (Server $server, int $fd) use ($handler) {
+ Coroutine::create(function () use ($server, $fd, $handler) {
+ $info = $server->getClientInfo($fd);
+ if (!is_array($info)) {
+ $server->close($fd);
+
+ return;
+ }
+
+ $ip = is_string($info['remote_ip'] ?? null) ? $info['remote_ip'] : '';
+ $port = is_int($info['remote_port'] ?? null) ? $info['remote_port'] : 0;
+
+ $connection = new SwooleConnection($server, $fd, $ip, $port);
+ \call_user_func($handler, $connection);
+ });
+ });
+
+ Runtime::enableCoroutine();
+ $this->server->start();
+ }
+
+ public function getName(): string
+ {
+ return 'swoole';
+ }
+}
diff --git a/src/SMTP/Client.php b/src/SMTP/Client.php
new file mode 100644
index 0000000..f679b5d
--- /dev/null
+++ b/src/SMTP/Client.php
@@ -0,0 +1,184 @@
+isValid($server)) {
+ throw new Exception('Server must be an IP address.');
+ }
+ }
+
+ /**
+ * @param list $rcptTo When empty, recipients are taken from the message.
+ */
+ public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void
+ {
+ $mailFrom ??= $message->from->address;
+ $rcptTo = $rcptTo !== [] ? $rcptTo : array_map(
+ fn ($address) => $address->address,
+ $message->allRecipients()
+ );
+
+ $socket = $this->connect();
+
+ try {
+ $this->expect($this->readResponse($socket), 220);
+ $this->command($socket, 'EHLO localhost');
+ $this->expect($this->readResponse($socket), 250);
+ $this->command($socket, "MAIL FROM:<{$mailFrom}>");
+ $this->expect($this->readResponse($socket), 250);
+
+ foreach ($rcptTo as $recipient) {
+ $this->command($socket, "RCPT TO:<{$recipient}>");
+ $this->expect($this->readResponse($socket), 250);
+ }
+
+ $this->command($socket, 'DATA');
+ $this->expect($this->readResponse($socket), 354);
+ $this->sendData($socket, $message->encode());
+ $this->expect($this->readResponse($socket), 250);
+ $this->command($socket, 'QUIT');
+ $this->readResponse($socket);
+ } finally {
+ fclose($socket);
+ }
+ }
+
+ /**
+ * @return resource
+ */
+ protected function connect()
+ {
+ $targetHost = $this->formatTcpHost($this->server);
+ $uri = "tcp://{$targetHost}:{$this->port}";
+
+ $errno = 0;
+ $errstr = '';
+ $socket = @stream_socket_client($uri, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT);
+
+ if ($socket === false) {
+ $errCode = is_int($errno) ? $errno : 0;
+ $errMsg = is_string($errstr) ? $errstr : 'Unknown error';
+ throw new Exception("Failed to connect to {$this->server}:{$this->port}: $errMsg ($errCode)");
+ }
+
+ stream_set_timeout($socket, $this->timeout);
+
+ return $socket;
+ }
+
+ /**
+ * @param resource $socket
+ */
+ protected function command(mixed $socket, string $command): void
+ {
+ $written = fwrite($socket, $command . "\r\n");
+
+ if ($written === false) {
+ throw new Exception('Failed to send SMTP command.');
+ }
+ }
+
+ /**
+ * @param resource $socket
+ */
+ protected function sendData(mixed $socket, string $payload): void
+ {
+ $payload = str_replace(["\r\n", "\r"], "\n", $payload);
+ $lines = explode("\n", $payload);
+
+ foreach ($lines as $line) {
+ if (str_starts_with($line, '.')) {
+ $line = '.' . $line;
+ }
+
+ $written = fwrite($socket, $line . "\r\n");
+
+ if ($written === false) {
+ throw new Exception('Failed to send message data.');
+ }
+ }
+
+ $written = fwrite($socket, ".\r\n");
+
+ if ($written === false) {
+ throw new Exception('Failed to end message data.');
+ }
+ }
+
+ /**
+ * @param resource $socket
+ */
+ protected function readResponse(mixed $socket): Response
+ {
+ $line = $this->readLine($socket);
+
+ if ($line === null) {
+ throw new Exception('SMTP connection closed unexpectedly.');
+ }
+
+ $response = Response::decode($line);
+
+ while ($response->multiline) {
+ $next = $this->readLine($socket);
+
+ if ($next === null) {
+ throw new Exception('Incomplete multiline SMTP response.');
+ }
+
+ $continuation = Response::decode($next);
+
+ if (!$continuation->multiline) {
+ $response = $continuation;
+ break;
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * @param resource $socket
+ */
+ protected function readLine(mixed $socket): ?string
+ {
+ if (!is_resource($socket)) {
+ return null;
+ }
+
+ $line = fgets($socket);
+
+ if ($line === false) {
+ return null;
+ }
+
+ return rtrim($line, "\r\n");
+ }
+
+ protected function expect(Response $response, int $code): void
+ {
+ if ($response->code !== $code) {
+ throw new Exception("Unexpected SMTP response {$response->code}: {$response->message}");
+ }
+ }
+
+ protected function formatTcpHost(string $host): string
+ {
+ if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
+ return '[' . $host . ']';
+ }
+
+ return $host;
+ }
+}
diff --git a/src/SMTP/Connection.php b/src/SMTP/Connection.php
new file mode 100644
index 0000000..5dede07
--- /dev/null
+++ b/src/SMTP/Connection.php
@@ -0,0 +1,19 @@
+ip;
+ }
+
+ public function getPort(): int
+ {
+ return $this->port;
+ }
+
+ public function readLine(): ?string
+ {
+ while (!str_contains($this->buffer, "\n")) {
+ $chunk = @socket_read($this->socket, 8192, PHP_BINARY_READ);
+
+ if ($chunk === false) {
+ $error = socket_last_error($this->socket);
+
+ if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) {
+ socket_clear_error($this->socket);
+ usleep(1000);
+ continue;
+ }
+
+ if ($this->buffer === '') {
+ return null;
+ }
+
+ break;
+ }
+
+ if ($chunk === '') {
+ if ($this->buffer === '') {
+ return null;
+ }
+
+ break;
+ }
+
+ $this->buffer .= $chunk;
+ }
+
+ $position = strpos($this->buffer, "\n");
+ if ($position === false) {
+ return null;
+ }
+
+ $line = substr($this->buffer, 0, $position + 1);
+ $this->buffer = substr($this->buffer, $position + 1);
+
+ return rtrim($line, "\r\n");
+ }
+
+ public function write(string $data): void
+ {
+ $total = strlen($data);
+ $sent = 0;
+
+ while ($sent < $total) {
+ $written = @socket_write($this->socket, substr($data, $sent));
+
+ if ($written === false) {
+ $error = socket_last_error($this->socket);
+
+ if (in_array($error, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true)) {
+ socket_clear_error($this->socket);
+ usleep(1000);
+ continue;
+ }
+
+ break;
+ }
+
+ $sent += $written;
+ }
+ }
+
+ public function close(): void
+ {
+ @socket_close($this->socket);
+ }
+}
diff --git a/src/SMTP/Connection/Swoole.php b/src/SMTP/Connection/Swoole.php
new file mode 100644
index 0000000..e5abde8
--- /dev/null
+++ b/src/SMTP/Connection/Swoole.php
@@ -0,0 +1,78 @@
+ip;
+ }
+
+ public function getPort(): int
+ {
+ return $this->port;
+ }
+
+ public function readLine(): ?string
+ {
+ while (!str_contains($this->buffer, "\n")) {
+ /** @var string|false $chunk */
+ $chunk = $this->server->recv($this->fd, 8192); // @phpstan-ignore method.notFound
+
+ if (!is_string($chunk) || $chunk === '') {
+ if ($this->buffer === '') {
+ return null;
+ }
+
+ break;
+ }
+
+ $this->buffer .= $chunk;
+ }
+
+ $position = strpos($this->buffer, "\n");
+ if ($position === false) {
+ return null;
+ }
+
+ $line = substr($this->buffer, 0, $position + 1);
+ $this->buffer = substr($this->buffer, $position + 1);
+
+ return rtrim($line, "\r\n");
+ }
+
+ public function write(string $data): void
+ {
+ $total = strlen($data);
+ $sent = 0;
+
+ while ($sent < $total) {
+ $written = $this->server->send($this->fd, substr($data, $sent));
+
+ if (!is_int($written) || $written === 0) {
+ break;
+ }
+
+ $sent += $written;
+ }
+ }
+
+ public function close(): void
+ {
+ $this->server->close($this->fd);
+ }
+}
diff --git a/src/SMTP/Exception/Message/DecodingException.php b/src/SMTP/Exception/Message/DecodingException.php
new file mode 100644
index 0000000..2127016
--- /dev/null
+++ b/src/SMTP/Exception/Message/DecodingException.php
@@ -0,0 +1,10 @@
+ $rcptTo
+ */
+ public function handle(Message $message, string $mailFrom, array $rcptTo): Result;
+}
diff --git a/src/SMTP/Handler/Memory.php b/src/SMTP/Handler/Memory.php
new file mode 100644
index 0000000..5f44556
--- /dev/null
+++ b/src/SMTP/Handler/Memory.php
@@ -0,0 +1,74 @@
+}> */
+ protected array $messages = [];
+
+ /**
+ * @param list $allowedRecipients When set, only these addresses are accepted.
+ * @param list $allowedSenders When set, only these MAIL FROM addresses are accepted.
+ * @param bool $rejectAll When true, all messages are rejected.
+ */
+ public function __construct(
+ protected array $allowedRecipients = [],
+ protected array $allowedSenders = [],
+ protected bool $rejectAll = false,
+ ) {
+ }
+
+ public function handle(Message $message, string $mailFrom, array $rcptTo): Result
+ {
+ if ($this->rejectAll) {
+ return Result::Rejected;
+ }
+
+ if ($this->allowedSenders !== [] && !in_array(strtolower($mailFrom), array_map('strtolower', $this->allowedSenders), true)) {
+ return Result::Rejected;
+ }
+
+ if ($this->allowedRecipients !== []) {
+ foreach ($rcptTo as $recipient) {
+ if (!in_array(strtolower($recipient), array_map('strtolower', $this->allowedRecipients), true)) {
+ return Result::Rejected;
+ }
+ }
+ }
+
+ $this->messages[] = [
+ 'message' => $message,
+ 'mailFrom' => $mailFrom,
+ 'rcptTo' => $rcptTo,
+ ];
+
+ return Result::Accepted;
+ }
+
+ public function getName(): string
+ {
+ return 'memory';
+ }
+
+ /**
+ * @return list}>
+ */
+ public function all(): array
+ {
+ return $this->messages;
+ }
+
+ public function count(): int
+ {
+ return count($this->messages);
+ }
+
+ public function clear(): void
+ {
+ $this->messages = [];
+ }
+}
diff --git a/src/SMTP/Handler/Proxy.php b/src/SMTP/Handler/Proxy.php
new file mode 100644
index 0000000..8ee0e0c
--- /dev/null
+++ b/src/SMTP/Handler/Proxy.php
@@ -0,0 +1,36 @@
+client = new Client($server, $port, $timeout);
+ }
+
+ public function handle(Message $message, string $mailFrom, array $rcptTo): Result
+ {
+ try {
+ $this->client->send($message, $mailFrom, $rcptTo);
+
+ return Result::Accepted;
+ } catch (\Throwable) {
+ return Result::Rejected;
+ }
+ }
+
+ public function getName(): string
+ {
+ return "Proxy ({$this->server}:{$this->port})";
+ }
+}
diff --git a/src/SMTP/Handler/Result.php b/src/SMTP/Handler/Result.php
new file mode 100644
index 0000000..aa04f3e
--- /dev/null
+++ b/src/SMTP/Handler/Result.php
@@ -0,0 +1,9 @@
+ $headers
+ * @param list $to
+ * @param list $cc
+ * @param list $bcc
+ */
+ public function __construct(
+ public readonly Address $from,
+ public readonly array $to,
+ public readonly string $subject,
+ public readonly string $body,
+ public readonly array $headers = [],
+ public readonly array $cc = [],
+ public readonly array $bcc = [],
+ public readonly string $contentType = 'text/plain',
+ ) {
+ if ($to === []) {
+ throw new \InvalidArgumentException('Message must have at least one recipient');
+ }
+ }
+
+ public static function create(
+ Address $from,
+ Address $to,
+ string $subject,
+ string $body,
+ string $contentType = 'text/plain',
+ ): self {
+ return new self($from, [$to], $subject, $body, contentType: $contentType);
+ }
+
+ public static function decode(string $raw): self
+ {
+ $raw = str_replace(["\r\n", "\r"], "\n", $raw);
+ $lines = explode("\n", $raw);
+
+ if ($raw === '') {
+ throw new DecodingException('Message body is empty');
+ }
+
+ /** @var array $headers */
+ $headers = [];
+ $bodyLines = [];
+ $inBody = false;
+ $lastHeaderName = null;
+ $lastHeaderValue = null;
+
+ foreach ($lines as $line) {
+ if (!$inBody) {
+ if ($line === '') {
+ $inBody = true;
+ continue;
+ }
+
+ if ($lastHeaderName !== null && (($line[0] ?? '') === ' ' || ($line[0] ?? '') === "\t")) {
+ $lastHeaderValue .= ' ' . ltrim($line);
+ $headers[$lastHeaderName] = $lastHeaderValue;
+ continue;
+ }
+
+ if (!str_contains($line, ':')) {
+ throw new DecodingException('Invalid header line');
+ }
+
+ [$name, $value] = explode(':', $line, 2);
+ $lastHeaderName = strtolower(trim($name));
+ $lastHeaderValue = trim($value);
+ $headers[$lastHeaderName] = $lastHeaderValue;
+ continue;
+ }
+
+ $bodyLines[] = $line;
+ }
+
+ $fromHeader = $headers['from'] ?? null;
+ if ($fromHeader === null) {
+ throw new DecodingException('Missing From header');
+ }
+
+ $toHeader = $headers['to'] ?? null;
+ if ($toHeader === null) {
+ throw new DecodingException('Missing To header');
+ }
+
+ $subject = $headers['subject'] ?? '';
+ $contentType = $headers['content-type'] ?? 'text/plain';
+ $body = implode("\n", $bodyLines);
+
+ return new self(
+ from: Address::parse($fromHeader),
+ to: self::parseAddressList($toHeader),
+ subject: $subject,
+ body: $body,
+ headers: self::filterCustomHeaders($headers),
+ cc: isset($headers['cc']) ? self::parseAddressList($headers['cc']) : [],
+ bcc: isset($headers['bcc']) ? self::parseAddressList($headers['bcc']) : [],
+ contentType: self::parseContentType($contentType),
+ );
+ }
+
+ public function encode(): string
+ {
+ $lines = [
+ 'From: ' . $this->from->encode(),
+ 'To: ' . self::encodeAddressList($this->to),
+ 'Subject: ' . $this->encodeHeaderValue($this->subject),
+ 'Date: ' . gmdate('D, d M Y H:i:s') . ' +0000',
+ 'MIME-Version: 1.0',
+ 'Content-Type: ' . $this->contentType . '; charset=UTF-8',
+ 'Content-Transfer-Encoding: 8bit',
+ ];
+
+ if ($this->cc !== []) {
+ $lines[] = 'Cc: ' . self::encodeAddressList($this->cc);
+ }
+
+ if ($this->bcc !== []) {
+ $lines[] = 'Bcc: ' . self::encodeAddressList($this->bcc);
+ }
+
+ foreach ($this->headers as $name => $value) {
+ $lines[] = $name . ': ' . $this->encodeHeaderValue($value);
+ }
+
+ $lines[] = '';
+ $lines[] = $this->body;
+
+ return implode("\r\n", $lines);
+ }
+
+ /**
+ * @return list
+ */
+ public function allRecipients(): array
+ {
+ return array_merge($this->to, $this->cc, $this->bcc);
+ }
+
+ /**
+ * @param array $headers
+ * @return array
+ */
+ private static function filterCustomHeaders(array $headers): array
+ {
+ $reserved = ['from', 'to', 'cc', 'bcc', 'subject', 'date', 'mime-version', 'content-type', 'content-transfer-encoding'];
+
+ $custom = [];
+ foreach ($headers as $name => $value) {
+ if (!in_array($name, $reserved, true)) {
+ $custom[$name] = $value;
+ }
+ }
+
+ return $custom;
+ }
+
+ /**
+ * @return list
+ */
+ private static function parseAddressList(string $value): array
+ {
+ $addresses = [];
+ $parts = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $value) ?: [];
+
+ foreach ($parts as $part) {
+ $part = trim($part);
+ if ($part === '') {
+ continue;
+ }
+
+ $addresses[] = Address::parse($part);
+ }
+
+ if ($addresses === []) {
+ throw new DecodingException('Recipient list cannot be empty');
+ }
+
+ return $addresses;
+ }
+
+ /**
+ * @param list $addresses
+ */
+ private static function encodeAddressList(array $addresses): string
+ {
+ return implode(', ', array_map(fn (Address $address) => $address->encode(), $addresses));
+ }
+
+ private static function parseContentType(string $value): string
+ {
+ $parts = explode(';', $value);
+
+ return trim($parts[0]);
+ }
+
+ private function encodeHeaderValue(string $value): string
+ {
+ if (preg_match('/[^\x20-\x7E]/', $value) === 1) {
+ return '=?UTF-8?B?' . base64_encode($value) . '?=';
+ }
+
+ return $value;
+ }
+}
diff --git a/src/SMTP/Message/Address.php b/src/SMTP/Message/Address.php
new file mode 100644
index 0000000..57bec8f
--- /dev/null
+++ b/src/SMTP/Message/Address.php
@@ -0,0 +1,53 @@
+]+)>$/', $value, $matches) === 1) {
+ return new self(trim($matches[1]));
+ }
+
+ if (preg_match('/^(.+?)\s+<([^>]+)>$/', $value, $matches) === 1) {
+ $name = trim($matches[1], " \t\"'");
+
+ return new self(trim($matches[2]), $name !== '' ? $name : null);
+ }
+
+ return new self($value);
+ }
+
+ public function encode(): string
+ {
+ if ($this->name === null) {
+ return $this->address;
+ }
+
+ $name = addcslashes($this->name, '"\\');
+
+ return "\"{$name}\" <{$this->address}>";
+ }
+}
diff --git a/src/SMTP/Protocol.php b/src/SMTP/Protocol.php
new file mode 100644
index 0000000..4eeb9a8
--- /dev/null
+++ b/src/SMTP/Protocol.php
@@ -0,0 +1,235 @@
+ */
+ protected array $rcptTo = [];
+
+ /** @var list */
+ protected array $dataBuffer = [];
+
+ public function __construct(
+ protected string $hostname = 'localhost',
+ ) {
+ }
+
+ public function greeting(): string
+ {
+ return "220 {$this->hostname} ESMTP Utopia SMTP\r\n";
+ }
+
+ /**
+ * @return list
+ */
+ public function handle(string $line): array
+ {
+ if ($this->state === self::STATE_DATA) {
+ return $this->handleDataLine($line);
+ }
+
+ $command = Command::parse($line);
+
+ return match ($command->verb) {
+ Command::HELO, Command::EHLO => $this->handleHelo($command),
+ Command::MAIL => $this->handleMail($command),
+ Command::RCPT => $this->handleRcpt($command),
+ Command::DATA => $this->handleDataStart(),
+ Command::RSET => $this->handleRset(),
+ Command::QUIT => $this->handleQuit(),
+ Command::NOOP => ["250 OK\r\n"],
+ Command::VRFY => ["502 VRFY disabled\r\n"],
+ Command::AUTH => ["502 AUTH not supported\r\n"],
+ 'EMPTY' => [],
+ default => ["500 Command not recognized\r\n"],
+ };
+ }
+
+ /**
+ * @return list
+ */
+ public function finalize(Handler $handler): array
+ {
+ if ($this->state !== self::STATE_DATA || $this->dataBuffer === []) {
+ return [];
+ }
+
+ return $this->deliver($handler);
+ }
+
+ public function getState(): int
+ {
+ return $this->state;
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleHelo(Command $command): array
+ {
+ $this->resetTransaction();
+ $this->heloName = $command->argument !== '' ? $command->argument : 'localhost';
+ $this->state = self::STATE_READY;
+
+ if ($command->verb === Command::EHLO) {
+ return [
+ "250-{$this->hostname} Hello {$this->heloName}\r\n",
+ "250-SIZE 10485760\r\n",
+ "250 8BITMIME\r\n",
+ ];
+ }
+
+ return ["250 {$this->hostname} Hello {$this->heloName}\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleMail(Command $command): array
+ {
+ if ($this->state < self::STATE_READY) {
+ return ["503 Send HELO/EHLO first\r\n"];
+ }
+
+ if ($this->state >= self::STATE_MAIL && $this->mailFrom !== '') {
+ return ["503 Nested MAIL not allowed\r\n"];
+ }
+
+ $address = $command->pathArgument();
+ if ($address === '') {
+ return ["501 Syntax: MAIL FROM:\r\n"];
+ }
+
+ $this->mailFrom = $address;
+ $this->state = self::STATE_MAIL;
+
+ return ["250 OK\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleRcpt(Command $command): array
+ {
+ if ($this->state < self::STATE_MAIL || $this->mailFrom === '') {
+ return ["503 Need MAIL FROM first\r\n"];
+ }
+
+ $address = $command->pathArgument();
+ if ($address === '') {
+ return ["501 Syntax: RCPT TO:\r\n"];
+ }
+
+ $this->rcptTo[] = $address;
+ $this->state = self::STATE_RCPT;
+
+ return ["250 OK\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleDataStart(): array
+ {
+ if ($this->state < self::STATE_RCPT || $this->rcptTo === []) {
+ return ["503 Need RCPT TO first\r\n"];
+ }
+
+ $this->state = self::STATE_DATA;
+ $this->dataBuffer = [];
+
+ return ["354 End data with .\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleDataLine(string $line): array
+ {
+ if ($line === '.') {
+ return [];
+ }
+
+ if (str_starts_with($line, '..')) {
+ $line = substr($line, 1);
+ }
+
+ $this->dataBuffer[] = $line;
+
+ return [];
+ }
+
+ /**
+ * @return list
+ */
+ protected function deliver(Handler $handler): array
+ {
+ $raw = implode("\r\n", $this->dataBuffer);
+
+ try {
+ $message = Message::decode($raw);
+ } catch (\Throwable) {
+ $this->resetTransaction();
+ $this->state = self::STATE_READY;
+
+ return ["554 Message rejected\r\n"];
+ }
+
+ $result = $handler->handle($message, $this->mailFrom, $this->rcptTo);
+ $this->resetTransaction();
+ $this->state = self::STATE_READY;
+
+ if ($result === Result::Accepted) {
+ return ["250 Message accepted\r\n"];
+ }
+
+ return ["550 Message rejected\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleRset(): array
+ {
+ $this->resetTransaction();
+ $this->state = $this->heloName !== '' ? self::STATE_READY : self::STATE_GREETING;
+
+ return ["250 OK\r\n"];
+ }
+
+ /**
+ * @return list
+ */
+ protected function handleQuit(): array
+ {
+ $this->heloName = 'QUIT';
+
+ return ["221 Bye\r\n"];
+ }
+
+ protected function resetTransaction(): void
+ {
+ $this->mailFrom = '';
+ $this->rcptTo = [];
+ $this->dataBuffer = [];
+ }
+}
diff --git a/src/SMTP/Protocol/Command.php b/src/SMTP/Protocol/Command.php
new file mode 100644
index 0000000..e554412
--- /dev/null
+++ b/src/SMTP/Protocol/Command.php
@@ -0,0 +1,51 @@
+]*)>/', $this->argument, $matches) === 1) {
+ return trim($matches[1]);
+ }
+
+ return trim($this->argument);
+ }
+}
diff --git a/src/SMTP/Protocol/Response.php b/src/SMTP/Protocol/Response.php
new file mode 100644
index 0000000..bcfc04e
--- /dev/null
+++ b/src/SMTP/Protocol/Response.php
@@ -0,0 +1,52 @@
+ 599) {
+ throw new DecodingException('SMTP response code must be between 100 and 599');
+ }
+ }
+
+ public static function decode(string $line): self
+ {
+ $line = rtrim($line, "\r\n");
+
+ if (strlen($line) < 4 || !ctype_digit(substr($line, 0, 3))) {
+ throw new DecodingException('Invalid SMTP response line');
+ }
+
+ $code = (int) substr($line, 0, 3);
+ $separator = $line[3] ?? ' ';
+ $message = trim(substr($line, 4));
+
+ if ($separator !== ' ' && $separator !== '-') {
+ throw new DecodingException('Invalid SMTP response separator');
+ }
+
+ return new self($code, $message, $separator === '-');
+ }
+
+ public function encode(): string
+ {
+ return sprintf("%d %s\r\n", $this->code, $this->message);
+ }
+
+ public function isPositiveCompletion(): bool
+ {
+ return $this->code >= 200 && $this->code < 300;
+ }
+
+ public function isPositiveIntermediate(): bool
+ {
+ return $this->code >= 300 && $this->code < 400;
+ }
+}
diff --git a/src/SMTP/Server.php b/src/SMTP/Server.php
new file mode 100644
index 0000000..1ab9164
--- /dev/null
+++ b/src/SMTP/Server.php
@@ -0,0 +1,168 @@
+ */
+ protected array $errors = [];
+
+ protected bool $debug = false;
+
+ protected ?Histogram $duration = null;
+ protected ?Counter $sessionsTotal = null;
+ protected ?Counter $messagesTotal = null;
+
+ public function __construct(
+ Adapter $adapter,
+ Handler $handler,
+ protected string $hostname = 'localhost',
+ ) {
+ $this->adapter = $adapter;
+ $this->handler = $handler;
+ $this->setTelemetry(new NoTelemetry());
+ }
+
+ public function setTelemetry(Telemetry $telemetry): void
+ {
+ $this->duration = $telemetry->createHistogram(
+ 'smtp.session.duration',
+ 's',
+ null,
+ ['ExplicitBucketBoundaries' => [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]]
+ );
+
+ $this->sessionsTotal = $telemetry->createCounter('smtp.sessions.total');
+ $this->messagesTotal = $telemetry->createCounter('smtp.messages.total');
+ }
+
+ public function error(callable $handler): self
+ {
+ $this->errors[] = $handler;
+ return $this;
+ }
+
+ /**
+ * @param callable(Server $server, int $workerId): void $handler
+ * @phpstan-param callable(Server $server, int $workerId): void $handler
+ */
+ public function onWorkerStart(callable $handler): self
+ {
+ $this->adapter->onWorkerStart(function (int $workerId) use ($handler) {
+ \call_user_func($handler, $this, $workerId);
+ });
+
+ return $this;
+ }
+
+ public function setDebug(bool $status): self
+ {
+ $this->debug = $status;
+ return $this;
+ }
+
+ protected function handleError(Throwable $error): void
+ {
+ foreach ($this->errors as $handler) {
+ call_user_func($handler, $error);
+ }
+ }
+
+ protected function onConnection(Connection $connection): void
+ {
+ $span = Span::init('smtp.session');
+ $span->set('client.ip', $connection->getIp());
+ $span->set('client.port', $connection->getPort());
+
+ $start = microtime(true);
+ $this->sessionsTotal?->add(1);
+
+ $protocol = new Protocol($this->hostname);
+ $connection->write($protocol->greeting());
+
+ $close = false;
+
+ try {
+ while (($line = $connection->readLine()) !== null) {
+ if ($this->debug) {
+ printf("C: %s\n", $line);
+ }
+
+ if ($protocol->getState() === Protocol::STATE_DATA && $line === '.') {
+ $responses = $protocol->finalize($this->handler);
+ foreach ($responses as $response) {
+ $connection->write($response);
+ if ($this->debug) {
+ printf("S: %s", $response);
+ }
+ }
+
+ if ($responses !== []) {
+ $this->messagesTotal?->add(1, [
+ 'handler' => $this->handler->getName(),
+ 'accepted' => str_contains($responses[0], '250'),
+ ]);
+ }
+
+ continue;
+ }
+
+ $responses = $protocol->handle($line);
+
+ /** @var list $responses */
+ foreach ($responses as $response) {
+ $connection->write($response);
+ if ($this->debug) {
+ printf("S: %s", $response);
+ }
+ }
+
+ if (strtoupper(rtrim($line)) === 'QUIT') {
+ $close = true;
+ break;
+ }
+ }
+ } catch (Throwable $error) {
+ $span->setError($error);
+ $this->handleError($error);
+ $connection->write("421 Service unavailable\r\n");
+ } finally {
+ $duration = microtime(true) - $start;
+ $this->duration?->record($duration);
+ $span->set('smtp.session.duration', $duration);
+ $span->finish();
+ $connection->close();
+ }
+
+ if ($close) {
+ return;
+ }
+ }
+
+ public function start(): void
+ {
+ try {
+ $this->adapter->onConnection($this->onConnection(...));
+ $this->adapter->start();
+ } catch (Throwable $error) {
+ $this->handleError($error);
+ }
+ }
+}
diff --git a/src/SMTP/Transport.php b/src/SMTP/Transport.php
new file mode 100644
index 0000000..fd3e29c
--- /dev/null
+++ b/src/SMTP/Transport.php
@@ -0,0 +1,16 @@
+ $rcptTo
+ */
+ public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void;
+}
diff --git a/src/SMTP/Transport/Socket.php b/src/SMTP/Transport/Socket.php
new file mode 100644
index 0000000..fd70fd1
--- /dev/null
+++ b/src/SMTP/Transport/Socket.php
@@ -0,0 +1,30 @@
+client = new Client($server, $port, $timeout);
+ }
+
+ public function send(Message $message, ?string $mailFrom = null, array $rcptTo = []): void
+ {
+ $this->client->send($message, $mailFrom, $rcptTo);
+ }
+
+ public function getName(): string
+ {
+ return "Socket ({$this->server}:{$this->port})";
+ }
+}
diff --git a/tests/e2e/SMTP/ClientTest.php b/tests/e2e/SMTP/ClientTest.php
new file mode 100644
index 0000000..aecb9ff
--- /dev/null
+++ b/tests/e2e/SMTP/ClientTest.php
@@ -0,0 +1,45 @@
+send($message, 'sender@appwrite.test', ['inbox@appwrite.test']);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testRejectedRecipientThrows(): void
+ {
+ $client = new Client('127.0.0.1', self::PORT);
+
+ $message = Message::create(
+ from: new Address('sender@appwrite.test'),
+ to: new Address('unknown@appwrite.test'),
+ subject: 'Rejected',
+ body: 'Should fail',
+ );
+
+ $this->expectException(\Exception::class);
+
+ $client->send($message, 'sender@appwrite.test', ['unknown@appwrite.test']);
+ }
+}
diff --git a/tests/resources/server.php b/tests/resources/server.php
new file mode 100644
index 0000000..a25f94b
--- /dev/null
+++ b/tests/resources/server.php
@@ -0,0 +1,26 @@
+setDebug((bool) (getenv('DEBUG') ?: false));
+$server->start();
diff --git a/tests/unit/SMTP/Adapter/AdapterTestCase.php b/tests/unit/SMTP/Adapter/AdapterTestCase.php
new file mode 100644
index 0000000..fbcab55
--- /dev/null
+++ b/tests/unit/SMTP/Adapter/AdapterTestCase.php
@@ -0,0 +1,52 @@
+createAdapter(0);
+
+ $this->assertSame($this->expectedName(), $adapter->getName());
+ }
+
+ public function testStartThrowsWhenConnectionHandlerMissing(): void
+ {
+ $adapter = $this->createAdapter(0);
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Connection handler not registered.');
+
+ $adapter->start();
+ }
+
+ public function testOnWorkerStartAcceptsCallback(): void
+ {
+ $adapter = $this->createAdapter(0);
+ $called = false;
+
+ $adapter->onWorkerStart(function (int $workerId) use (&$called) {
+ $called = $workerId === 0 || $workerId > 0;
+ });
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function testOnConnectionAcceptsCallback(): void
+ {
+ $adapter = $this->createAdapter(0);
+
+ $adapter->onConnection(function () {
+ });
+
+ $this->addToAssertionCount(1);
+ }
+}
diff --git a/tests/unit/SMTP/Adapter/NativeTest.php b/tests/unit/SMTP/Adapter/NativeTest.php
new file mode 100644
index 0000000..685872a
--- /dev/null
+++ b/tests/unit/SMTP/Adapter/NativeTest.php
@@ -0,0 +1,19 @@
+expectException(\Exception::class);
+ $this->expectExceptionMessage('Server must be an IP address.');
+
+ new Client('not-an-ip');
+ }
+}
diff --git a/tests/unit/SMTP/Connection/BufferedConnection.php b/tests/unit/SMTP/Connection/BufferedConnection.php
new file mode 100644
index 0000000..da46960
--- /dev/null
+++ b/tests/unit/SMTP/Connection/BufferedConnection.php
@@ -0,0 +1,60 @@
+ip;
+ }
+
+ public function getPort(): int
+ {
+ return $this->port;
+ }
+
+ public function readLine(): ?string
+ {
+ if ($this->offset >= strlen($this->payload)) {
+ return null;
+ }
+
+ $remaining = substr($this->payload, $this->offset);
+ $position = strpos($remaining, "\n");
+
+ if ($position === false) {
+ $line = $remaining;
+ $this->offset = strlen($this->payload);
+
+ return rtrim($line, "\r\n");
+ }
+
+ $line = substr($remaining, 0, $position + 1);
+ $this->offset += $position + 1;
+
+ return rtrim($line, "\r\n");
+ }
+
+ public function write(string $data): void
+ {
+ }
+
+ public function close(): void
+ {
+ }
+}
diff --git a/tests/unit/SMTP/Connection/BufferedConnectionTest.php b/tests/unit/SMTP/Connection/BufferedConnectionTest.php
new file mode 100644
index 0000000..d7716c4
--- /dev/null
+++ b/tests/unit/SMTP/Connection/BufferedConnectionTest.php
@@ -0,0 +1,14 @@
+ $chunks
+ */
+ protected function createConnection(array $chunks): BufferedConnection
+ {
+ return new BufferedConnection(implode('', $chunks));
+ }
+}
diff --git a/tests/unit/SMTP/Connection/ConnectionTestCase.php b/tests/unit/SMTP/Connection/ConnectionTestCase.php
new file mode 100644
index 0000000..e278cd8
--- /dev/null
+++ b/tests/unit/SMTP/Connection/ConnectionTestCase.php
@@ -0,0 +1,48 @@
+ $chunks
+ */
+ abstract protected function createConnection(array $chunks): Connection;
+
+ public function testReadLineReturnsBufferedLines(): void
+ {
+ $connection = $this->createConnection(["220 localhost\r\n", "250 OK\r\n"]);
+
+ $this->assertSame('220 localhost', $connection->readLine());
+ $this->assertSame('250 OK', $connection->readLine());
+ $this->assertNull($connection->readLine());
+ }
+
+ public function testReadLineSplitsAcrossChunks(): void
+ {
+ $connection = $this->createConnection(['250-', "OK\r\n"]);
+
+ $this->assertSame('250-OK', $connection->readLine());
+ $this->assertNull($connection->readLine());
+ }
+
+ public function testGetIpAndPort(): void
+ {
+ $connection = $this->createConnection([]);
+
+ $this->assertSame('127.0.0.1', $connection->getIp());
+ $this->assertGreaterThan(0, $connection->getPort());
+ }
+
+ public function testWriteAndClose(): void
+ {
+ $connection = $this->createConnection([]);
+ $connection->write("221 Bye\r\n");
+ $connection->close();
+
+ $this->addToAssertionCount(1);
+ }
+}
diff --git a/tests/unit/SMTP/Connection/SocketTest.php b/tests/unit/SMTP/Connection/SocketTest.php
new file mode 100644
index 0000000..a35f003
--- /dev/null
+++ b/tests/unit/SMTP/Connection/SocketTest.php
@@ -0,0 +1,110 @@
+ $chunks
+ */
+ protected function createConnection(array $chunks): Connection
+ {
+ $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (!$server instanceof PhpSocket) {
+ $this->markTestSkipped('Could not create TCP socket.');
+ }
+
+ socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1);
+ socket_bind($server, '127.0.0.1', 0);
+ socket_listen($server, 1);
+
+ $port = 0;
+ socket_getsockname($server, $address, $port);
+ $port = is_int($port) ? $port : 0;
+
+ if ($port === 0) {
+ socket_close($server);
+ $this->markTestSkipped('Could not resolve ephemeral port.');
+ }
+
+ $client = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (!$client instanceof PhpSocket) {
+ socket_close($server);
+ $this->markTestSkipped('Could not create client socket.');
+ }
+
+ socket_connect($client, '127.0.0.1', $port);
+ $peer = socket_accept($server);
+
+ if (!$peer instanceof PhpSocket) {
+ socket_close($server);
+ socket_close($client);
+ $this->markTestSkipped('Could not accept peer socket.');
+ }
+
+ foreach ($chunks as $chunk) {
+ socket_write($peer, $chunk);
+ }
+
+ socket_close($peer);
+ socket_close($server);
+
+ return new Socket($client, '127.0.0.1', $port);
+ }
+
+ public function testReadLineSplitsAcrossChunks(): void
+ {
+ $server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (!$server instanceof PhpSocket) {
+ $this->markTestSkipped('Could not create TCP socket.');
+ }
+
+ socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1);
+ socket_bind($server, '127.0.0.1', 0);
+ socket_listen($server, 1);
+
+ $port = 0;
+ socket_getsockname($server, $address, $port);
+ $port = is_int($port) ? $port : 0;
+
+ $client = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
+ if (!$client instanceof PhpSocket || $port === 0) {
+ socket_close($server);
+ $this->markTestSkipped('Could not create client socket.');
+ }
+
+ socket_connect($client, '127.0.0.1', $port);
+ $peer = socket_accept($server);
+
+ if (!$peer instanceof PhpSocket) {
+ socket_close($server);
+ socket_close($client);
+ $this->markTestSkipped('Could not accept peer socket.');
+ }
+
+ socket_write($peer, '250-');
+
+ $connection = new Socket($client, '127.0.0.1', $port);
+
+ socket_write($peer, "OK\r\n");
+ socket_close($peer);
+ socket_close($server);
+
+ $this->assertSame('250-OK', $connection->readLine());
+ }
+
+ public function testReadLineMatchesBufferedImplementation(): void
+ {
+ $payload = "220 localhost\r\n250 OK\r\n";
+ $buffered = new BufferedConnection($payload);
+ $socket = $this->createConnection(["220 localhost\r\n", "250 OK\r\n"]);
+
+ $this->assertSame($buffered->readLine(), $socket->readLine());
+ $this->assertSame($buffered->readLine(), $socket->readLine());
+ $this->assertNull($socket->readLine());
+ }
+}
diff --git a/tests/unit/SMTP/Connection/SwooleTest.php b/tests/unit/SMTP/Connection/SwooleTest.php
new file mode 100644
index 0000000..8bba4e3
--- /dev/null
+++ b/tests/unit/SMTP/Connection/SwooleTest.php
@@ -0,0 +1,63 @@
+on('Start', function (Server $server) use (&$port) {
+ $socketName = $server->getSocket()?->getsockname();
+ if (is_array($socketName) && isset($socketName['port']) && is_int($socketName['port'])) {
+ $port = $socketName['port'];
+ }
+ });
+
+ $server->on('Connect', function (Server $server, int $fd) use (&$connection, $port, $payload) {
+ $connection = new SwooleConnection($server, $fd, '127.0.0.1', 2525);
+
+ Coroutine::create(function () use ($port, $payload) {
+ usleep(50000);
+ $client = new Coroutine\Socket(AF_INET, SOCK_STREAM, 0);
+ if ($client->connect('127.0.0.1', $port)) {
+ $client->send($payload);
+ $client->close();
+ }
+ });
+ });
+
+ Coroutine::create(function () use ($server, &$connection, &$lines) {
+ $server->start();
+
+ if (!$connection instanceof SwooleConnection) {
+ return;
+ }
+
+ $lines[] = $connection->readLine();
+ $lines[] = $connection->readLine();
+ $lines[] = $connection->readLine();
+ });
+ });
+
+ $this->assertSame($buffered->readLine(), $lines[0] ?? null);
+ $this->assertSame($buffered->readLine(), $lines[1] ?? null);
+ $this->assertArrayHasKey(2, $lines);
+ $this->assertNull($lines[2]);
+ }
+}
diff --git a/tests/unit/SMTP/Handler/MemoryTest.php b/tests/unit/SMTP/Handler/MemoryTest.php
new file mode 100644
index 0000000..eb79492
--- /dev/null
+++ b/tests/unit/SMTP/Handler/MemoryTest.php
@@ -0,0 +1,30 @@
+handle($message, 'sender@example.com', ['allowed@example.com']);
+ $rejected = $handler->handle($message, 'sender@example.com', ['blocked@example.com']);
+
+ $this->assertSame(Result::Accepted, $accepted);
+ $this->assertSame(Result::Rejected, $rejected);
+ $this->assertSame(1, $handler->count());
+ }
+}
diff --git a/tests/unit/SMTP/Message/AddressTest.php b/tests/unit/SMTP/Message/AddressTest.php
new file mode 100644
index 0000000..b7604aa
--- /dev/null
+++ b/tests/unit/SMTP/Message/AddressTest.php
@@ -0,0 +1,40 @@
+');
+
+ $this->assertSame('user@example.com', $address->address);
+ $this->assertNull($address->name);
+ }
+
+ public function testParseNamedAddress(): void
+ {
+ $address = Address::parse('Example User ');
+
+ $this->assertSame('user@example.com', $address->address);
+ $this->assertSame('Example User', $address->name);
+ }
+
+ public function testEncodeNamedAddress(): void
+ {
+ $address = new Address('user@example.com', 'Example User');
+
+ $this->assertSame('"Example User" ', $address->encode());
+ }
+
+ public function testParseThrowsOnEmptyValue(): void
+ {
+ $this->expectException(DecodingException::class);
+
+ Address::parse('');
+ }
+}
diff --git a/tests/unit/SMTP/MessageTest.php b/tests/unit/SMTP/MessageTest.php
new file mode 100644
index 0000000..a3995d3
--- /dev/null
+++ b/tests/unit/SMTP/MessageTest.php
@@ -0,0 +1,89 @@
+encode();
+ $decoded = Message::decode($encoded);
+
+ $this->assertSame('sender@example.com', $decoded->from->address);
+ $this->assertSame('Sender', $decoded->from->name);
+ $this->assertSame('recipient@example.com', $decoded->to[0]->address);
+ $this->assertSame('Hello SMTP', $decoded->subject);
+ $this->assertSame("Line one\nLine two", $decoded->body);
+ }
+
+ public function testDecodeParsesCcHeaders(): void
+ {
+ $raw = implode("\r\n", [
+ 'From: sender@example.com',
+ 'To: one@example.com, two@example.com',
+ 'Cc: cc@example.com',
+ 'Subject: Subject',
+ 'MIME-Version: 1.0',
+ 'Content-Type: text/plain; charset=UTF-8',
+ '',
+ 'Body',
+ ]);
+
+ $message = Message::decode($raw);
+
+ $this->assertCount(2, $message->to);
+ $this->assertCount(1, $message->cc);
+ $this->assertSame('cc@example.com', $message->cc[0]->address);
+ }
+
+ public function testAllRecipientsIncludesCcAndBcc(): void
+ {
+ $message = new Message(
+ from: new Address('sender@example.com'),
+ to: [new Address('one@example.com')],
+ subject: 'Test',
+ body: 'Body',
+ cc: [new Address('cc@example.com')],
+ bcc: [new Address('bcc@example.com')],
+ );
+
+ $recipients = array_map(fn (Address $address) => $address->address, $message->allRecipients());
+
+ $this->assertEqualsCanonicalizing(
+ ['one@example.com', 'cc@example.com', 'bcc@example.com'],
+ $recipients,
+ );
+ }
+
+ public function testDecodeThrowsWhenFromMissing(): void
+ {
+ $this->expectException(DecodingException::class);
+ $this->expectExceptionMessage('Missing From header');
+
+ Message::decode("To: a@example.com\r\n\r\nBody");
+ }
+
+ public function testConstructorRequiresRecipients(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ new Message(
+ from: new Address('sender@example.com'),
+ to: [],
+ subject: 'Empty',
+ body: 'Body',
+ );
+ }
+}
diff --git a/tests/unit/SMTP/Protocol/CommandTest.php b/tests/unit/SMTP/Protocol/CommandTest.php
new file mode 100644
index 0000000..cb12dce
--- /dev/null
+++ b/tests/unit/SMTP/Protocol/CommandTest.php
@@ -0,0 +1,25 @@
+assertSame('QUIT', $command->verb);
+ $this->assertSame('', $command->argument);
+ }
+
+ public function testParseWithArgument(): void
+ {
+ $command = Command::parse("MAIL FROM:\r\n");
+
+ $this->assertSame('MAIL', $command->verb);
+ $this->assertSame('sender@example.com', $command->pathArgument());
+ }
+}
diff --git a/tests/unit/SMTP/Protocol/ResponseTest.php b/tests/unit/SMTP/Protocol/ResponseTest.php
new file mode 100644
index 0000000..bcaa3cb
--- /dev/null
+++ b/tests/unit/SMTP/Protocol/ResponseTest.php
@@ -0,0 +1,41 @@
+assertSame(250, $response->code);
+ $this->assertSame('OK', $response->message);
+ $this->assertFalse($response->multiline);
+ $this->assertTrue($response->isPositiveCompletion());
+ }
+
+ public function testDecodeMultilinePrefix(): void
+ {
+ $response = Response::decode("250-Hello localhost\r\n");
+
+ $this->assertTrue($response->multiline);
+ }
+
+ public function testEncode(): void
+ {
+ $response = new Response(354, 'Start mail input');
+
+ $this->assertSame("354 Start mail input\r\n", $response->encode());
+ }
+
+ public function testDecodeThrowsOnInvalidLine(): void
+ {
+ $this->expectException(DecodingException::class);
+
+ Response::decode('invalid');
+ }
+}
diff --git a/tests/unit/SMTP/ProtocolTest.php b/tests/unit/SMTP/ProtocolTest.php
new file mode 100644
index 0000000..430fea4
--- /dev/null
+++ b/tests/unit/SMTP/ProtocolTest.php
@@ -0,0 +1,92 @@
+greeting();
+ $responses = array_merge($responses, $protocol->handle('EHLO client.test'));
+ $responses = array_merge($responses, $protocol->handle('MAIL FROM:'));
+ $responses = array_merge($responses, $protocol->handle('RCPT TO:'));
+
+ $message = Message::create(
+ from: new Address('sender@test.com'),
+ to: new Address('inbox@test.com'),
+ subject: 'Protocol',
+ body: 'Hello',
+ );
+
+ $dataResponses = $protocol->handle('DATA');
+ $this->assertSame(["354 End data with .\r\n"], $dataResponses);
+
+ foreach (explode("\r\n", $message->encode()) as $line) {
+ $protocol->handle($line);
+ }
+
+ $responses = $protocol->finalize($handler);
+
+ $this->assertSame(["250 Message accepted\r\n"], $responses);
+ $this->assertSame(1, $handler->count());
+ }
+
+ public function testMailRequiresHelo(): void
+ {
+ $protocol = new Protocol('mail.test');
+
+ $responses = $protocol->handle('MAIL FROM:');
+
+ $this->assertSame(["503 Send HELO/EHLO first\r\n"], $responses);
+ }
+
+ public function testRcptRequiresMailFrom(): void
+ {
+ $protocol = new Protocol('mail.test');
+ $protocol->handle('EHLO client.test');
+
+ $responses = $protocol->handle('RCPT TO:');
+
+ $this->assertSame(["503 Need MAIL FROM first\r\n"], $responses);
+ }
+
+ public function testMemoryHandlerCanReject(): void
+ {
+ $handler = new Memory(rejectAll: true);
+ $protocol = new Protocol('mail.test');
+ $protocol->handle('EHLO client.test');
+ $protocol->handle('MAIL FROM:');
+ $protocol->handle('RCPT TO:');
+ $protocol->handle('DATA');
+ $protocol->handle('From: sender@test.com');
+ $protocol->handle('To: inbox@test.com');
+ $protocol->handle('Subject: Test');
+ $protocol->handle('');
+ $protocol->handle('Body');
+
+ $responses = $protocol->finalize($handler);
+
+ $this->assertSame(["550 Message rejected\r\n"], $responses);
+ $this->assertSame(0, $handler->count());
+ }
+
+ public function testQuitReturnsBye(): void
+ {
+ $protocol = new Protocol('mail.test');
+ $protocol->handle('EHLO client.test');
+
+ $responses = $protocol->handle('QUIT');
+
+ $this->assertSame(["221 Bye\r\n"], $responses);
+ }
+}
diff --git a/tests/unit/SMTP/Server/SwooleServerTest.php b/tests/unit/SMTP/Server/SwooleServerTest.php
new file mode 100644
index 0000000..a1e2713
--- /dev/null
+++ b/tests/unit/SMTP/Server/SwooleServerTest.php
@@ -0,0 +1,73 @@
+ ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $process = proc_open(
+ ['php', $script],
+ $descriptor,
+ $pipes,
+ dirname($script),
+ [
+ 'PORT' => (string) $port,
+ 'ADAPTER' => 'swoole',
+ ],
+ );
+
+ if (!is_resource($process)) {
+ $this->fail('Could not start Swoole SMTP server process.');
+ }
+
+ try {
+ $this->waitForPort($port);
+
+ $client = new Client('127.0.0.1', $port);
+ $message = Message::create(
+ new Address('sender@appwrite.test'),
+ new Address('inbox@appwrite.test'),
+ 'Swoole SMTP',
+ 'Delivered through Swoole adapter',
+ );
+
+ $client->send($message, 'sender@appwrite.test', ['inbox@appwrite.test']);
+ $this->addToAssertionCount(1);
+ } finally {
+ proc_terminate($process);
+ proc_close($process);
+ }
+ }
+
+ protected function waitForPort(int $port, int $attempts = 50): void
+ {
+ for ($i = 0; $i < $attempts; $i++) {
+ $socket = @fsockopen('127.0.0.1', $port);
+ if ($socket !== false) {
+ fclose($socket);
+
+ return;
+ }
+
+ usleep(100000);
+ }
+
+ $this->fail("SMTP server did not listen on port {$port}.");
+ }
+}
diff --git a/tests/unit/SMTP/ServerTest.php b/tests/unit/SMTP/ServerTest.php
new file mode 100644
index 0000000..ee7aed6
--- /dev/null
+++ b/tests/unit/SMTP/ServerTest.php
@@ -0,0 +1,108 @@
+start();
+
+ $this->assertSame(1, $handler->count());
+ $stored = $handler->all()[0];
+ $this->assertSame('sender@test.com', $stored['mailFrom']);
+ $this->assertSame(['inbox@test.com'], $stored['rcptTo']);
+ $this->assertSame('Hello server', $stored['message']->body);
+ }
+}
+
+final class TestAdapter extends Adapter
+{
+ /** @var callable(Connection $connection): void|null */
+ private mixed $handler = null;
+
+ public function onWorkerStart(callable $callback): void
+ {
+ $callback(0);
+ }
+
+ public function onConnection(callable $callback): void
+ {
+ $this->handler = $callback;
+ }
+
+ public function start(): void
+ {
+ if ($this->handler === null) {
+ return;
+ }
+
+ $message = Message::create(
+ new Address('sender@test.com'),
+ new Address('inbox@test.com'),
+ 'Subject',
+ 'Hello server',
+ );
+
+ $lines = [
+ 'EHLO client.test',
+ 'MAIL FROM:',
+ 'RCPT TO:',
+ 'DATA',
+ ...explode("\r\n", $message->encode()),
+ '.',
+ 'QUIT',
+ ];
+
+ \call_user_func($this->handler, new TestConnection($lines));
+ }
+}
+
+final class TestConnection implements Connection
+{
+ private int $index = 0;
+
+ /**
+ * @param list $lines
+ */
+ public function __construct(private readonly array $lines)
+ {
+ }
+
+ public function getIp(): string
+ {
+ return '127.0.0.1';
+ }
+
+ public function getPort(): int
+ {
+ return 40000;
+ }
+
+ public function readLine(): ?string
+ {
+ if (!isset($this->lines[$this->index])) {
+ return null;
+ }
+
+ return $this->lines[$this->index++];
+ }
+
+ public function write(string $data): void
+ {
+ }
+
+ public function close(): void
+ {
+ }
+}
diff --git a/tests/unit/SMTP/Transport/SocketTest.php b/tests/unit/SMTP/Transport/SocketTest.php
new file mode 100644
index 0000000..495edcd
--- /dev/null
+++ b/tests/unit/SMTP/Transport/SocketTest.php
@@ -0,0 +1,16 @@
+assertSame('Socket (10.0.0.5:2525)', $transport->getName());
+ }
+}