From 23f2b6432bda9f756f24ebd5f6ebc282d60b8fb5 Mon Sep 17 00:00:00 2001 From: Ron Rise Date: Mon, 29 Dec 2025 16:27:01 +0000 Subject: [PATCH] Initial commit --- .allowed-licenses.php | 14 + .dev/docker-compose.yml | 192 + .dev/ssl/localhost.crt | 83 + .dev/ssl/localhost.key | 6 + .dev/ssl/traefik.yml | 14 + .dockerignore | 5 + .gitea/workflows/build.yml | 79 + .gitea/workflows/tests.yml | 268 + .gitignore | 6 + .rr.yaml | 35 + .run/ Compose Deployment.run.xml | 10 + .run/All.run.xml | 8 + .run/Lint_fix.run.xml | 8 + .run/Main.run.xml | 11 + Dockerfile | 63 + README.md | 150 + bin/migrate.sh | 4 + bin/pcov.sh | 19 + bin/xdebug.sh | 24 + cli.php | 11 + composer.json | 75 + composer.lock | 6851 +++++++++++++++++ config.php | 88 + .../000001_create_users_table.down.sql | 1 + .../000001_create_users_table.up.sql | 13 + generated/GRPC/GPBMetadata/Example.php | 25 + generated/GRPC/Greeter/GreeterInterface.php | 22 + generated/GRPC/Greeter/HelloReply.php | 61 + generated/GRPC/Greeter/HelloRequest.php | 61 + generated/README.md | 3 + grpc-worker.php | 14 + makefile | 187 + migrations.Dockerfile | 8 + phpstan.neon | 3 + phpunit.xml | 24 + protos/example.proto | 23 + server.php | 14 + src/Api.php | 134 + src/Async/Brokers/Broker.php | 19 + src/Async/Brokers/BrokerInterface.php | 21 + src/Async/Brokers/Kafka.php | 94 + src/Async/Brokers/RabbitMQ.php | 36 + src/Async/Brokers/Redis.php | 190 + src/Async/Brokers/Sqs.php | 36 + src/Async/Consumer.php | 173 + src/Async/Handlers/HandlerInterface.php | 12 + src/Async/Handlers/SayHelloHandler.php | 21 + src/Async/Messages/Message.php | 102 + src/Async/Messages/SayHelloMessage.php | 41 + src/Async/Queues/DefaultQueue.php | 13 + src/Async/Queues/Queue.php | 28 + src/Attributes/Async/HandlesMessage.php | 36 + src/Attributes/CommandBus/HandlesCommand.php | 22 + src/Attributes/Events/ListensFor.php | 28 + src/Attributes/Guards/Jwt.php | 66 + src/Attributes/Guards/RequireAllScopes.php | 12 + src/Attributes/Guards/Scope.php | 38 + src/Cli/App.php | 46 + src/Cli/Commands/CommandInterface.php | 15 + src/Cli/Commands/DemoCommand.php | 45 + src/Cli/Commands/Queue/Start.php | 32 + src/Cli/Commands/Queue/TestJob.php | 34 + src/CommandBus/AttributeLocator.php | 49 + src/CommandBus/Commands/Command.php | 9 + src/CommandBus/Commands/ExampleCommand.php | 18 + src/CommandBus/Handlers/CommandHandler.php | 9 + .../Handlers/CommandHandlerInterface.php | 12 + src/CommandBus/Handlers/ExampleHandler.php | 30 + src/Controllers/Controller.php | 75 + src/Controllers/ControllerInterface.php | 56 + src/Controllers/HealthcheckController.php | 63 + src/Controllers/IndexController.php | 66 + src/Controllers/OpenApiController.php | 41 + src/Docs/TokenSecurity.php | 19 + src/Docs/UnauthorizedResponse.php | 25 + src/Events/Dispatcher.php | 246 + src/Events/Listeners/Database/Connected.php | 35 + src/Events/Listeners/Listener.php | 14 + src/Events/Listeners/ListenerInterface.php | 19 + src/Events/Subscribers/Subscriber.php | 15 + .../Subscribers/SubscriberInterface.php | 10 + src/Grpc.php | 54 + src/GrpcHandlers/GreeterHandler.php | 25 + src/Helpers/Env.php | 30 + src/Helpers/Ulid.php | 22 + src/Helpers/Version.php | 10 + src/Http/JsonResponseFactory.php | 42 + src/Http/Middleware/CorsMiddleware.php | 72 + src/Http/Middleware/JwtMiddleware.php | 322 + src/Http/Middleware/Middleware.php | 71 + src/Http/Middleware/ScopeMiddleware.php | 117 + src/Http/Responses/GenericResponse.php | 29 + src/Http/Responses/NotFoundResponse.php | 38 + src/Http/Responses/ServerErrorResponse.php | 56 + src/Kernel.php | 97 + src/Log/Logger.php | 236 + src/Models/Model.php | 17 + src/Models/User.php | 77 + src/Services/Facade.php | 278 + src/Services/Facades/Broker.php | 29 + src/Services/Facades/CommandBus.php | 27 + src/Services/Facades/Config.php | 32 + src/Services/Facades/Dispatcher.php | 35 + src/Services/Facades/Guzzle.php | 28 + src/Services/Facades/Logger.php | 36 + src/Services/Facades/Redis.php | 34 + src/Services/Facades/RoadRunnerLogger.php | 39 + .../BrokerServiceProvider.php | 54 + .../ServiceProviders/CommandBusProvider.php | 38 + .../DispatcherServiceProvider.php | 28 + .../LoggerServiceProvider.php | 29 + .../ServiceProviders/RedisServiceProvider.php | 37 + tests/Attributes/Guards/JwtTest.php | 49 + tests/Attributes/Guards/ScopeTest.php | 43 + tests/Attributes/HandlesMessageTest.php | 25 + tests/CommandBus/AttributeLocatorTest.php | 36 + .../Handlers/ExampleHandlerTest.php | 32 + tests/Controllers/AbstractController.php | 16 + tests/Controllers/ControllerTest.php | 54 + tests/Controllers/IndexControllerTest.php | 48 + tests/Controllers/OpenApiControllerTest.php | 33 + tests/Events/DispatcherTest.php | 177 + tests/Events/Listeners/ConnectedTest.php | 47 + tests/Facades/AbstractFacade.php | 32 + tests/Facades/DispatcherTest.php | 20 + tests/Facades/GuzzleTest.php | 21 + tests/Facades/LoggerTest.php | 20 + tests/Facades/RedisTest.php | 21 + tests/GrpcHandlers/GreeterHandlerTest.php | 36 + tests/Helpers/EnvTest.php | 46 + tests/Helpers/UlidTest.php | 19 + tests/Http/JsonResponseFactoryTest.php | 50 + tests/Http/Middleware/CorsMiddlewareTest.php | 83 + tests/Http/Middleware/JwtMiddlewareTest.php | 366 + tests/Http/Middleware/Middleware.php | 32 + tests/Http/Middleware/ScopeMiddlewareTest.php | 111 + tests/Http/Responses/NotFoundResponseTest.php | 21 + .../Responses/ServerErrorResponseTest.php | 93 + tests/Log/LoggerRpcTest.php | 163 + tests/Log/LoggerTest.php | 229 + tests/Models/UserTest.php | 30 + .../AbstractServiceProvider.php | 43 + .../CommandBusServiceProviderTest.php | 15 + .../DispatcherServiceProviderTest.php | 15 + .../LoggerServiceProviderTest.php | 15 + .../RedisServiceProviderTest.php | 15 + tests/Unit.php | 48 + 147 files changed, 14731 insertions(+) create mode 100644 .allowed-licenses.php create mode 100644 .dev/docker-compose.yml create mode 100644 .dev/ssl/localhost.crt create mode 100644 .dev/ssl/localhost.key create mode 100644 .dev/ssl/traefik.yml create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .rr.yaml create mode 100644 .run/ Compose Deployment.run.xml create mode 100644 .run/All.run.xml create mode 100644 .run/Lint_fix.run.xml create mode 100644 .run/Main.run.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 bin/migrate.sh create mode 100755 bin/pcov.sh create mode 100755 bin/xdebug.sh create mode 100755 cli.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config.php create mode 100644 db/migrations/000001_create_users_table.down.sql create mode 100644 db/migrations/000001_create_users_table.up.sql create mode 100644 generated/GRPC/GPBMetadata/Example.php create mode 100644 generated/GRPC/Greeter/GreeterInterface.php create mode 100644 generated/GRPC/Greeter/HelloReply.php create mode 100644 generated/GRPC/Greeter/HelloRequest.php create mode 100644 generated/README.md create mode 100644 grpc-worker.php create mode 100644 makefile create mode 100644 migrations.Dockerfile create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 protos/example.proto create mode 100644 server.php create mode 100644 src/Api.php create mode 100644 src/Async/Brokers/Broker.php create mode 100644 src/Async/Brokers/BrokerInterface.php create mode 100644 src/Async/Brokers/Kafka.php create mode 100644 src/Async/Brokers/RabbitMQ.php create mode 100644 src/Async/Brokers/Redis.php create mode 100644 src/Async/Brokers/Sqs.php create mode 100644 src/Async/Consumer.php create mode 100644 src/Async/Handlers/HandlerInterface.php create mode 100644 src/Async/Handlers/SayHelloHandler.php create mode 100644 src/Async/Messages/Message.php create mode 100644 src/Async/Messages/SayHelloMessage.php create mode 100644 src/Async/Queues/DefaultQueue.php create mode 100644 src/Async/Queues/Queue.php create mode 100644 src/Attributes/Async/HandlesMessage.php create mode 100644 src/Attributes/CommandBus/HandlesCommand.php create mode 100644 src/Attributes/Events/ListensFor.php create mode 100644 src/Attributes/Guards/Jwt.php create mode 100644 src/Attributes/Guards/RequireAllScopes.php create mode 100644 src/Attributes/Guards/Scope.php create mode 100644 src/Cli/App.php create mode 100644 src/Cli/Commands/CommandInterface.php create mode 100644 src/Cli/Commands/DemoCommand.php create mode 100644 src/Cli/Commands/Queue/Start.php create mode 100644 src/Cli/Commands/Queue/TestJob.php create mode 100644 src/CommandBus/AttributeLocator.php create mode 100644 src/CommandBus/Commands/Command.php create mode 100644 src/CommandBus/Commands/ExampleCommand.php create mode 100644 src/CommandBus/Handlers/CommandHandler.php create mode 100644 src/CommandBus/Handlers/CommandHandlerInterface.php create mode 100644 src/CommandBus/Handlers/ExampleHandler.php create mode 100644 src/Controllers/Controller.php create mode 100644 src/Controllers/ControllerInterface.php create mode 100644 src/Controllers/HealthcheckController.php create mode 100644 src/Controllers/IndexController.php create mode 100644 src/Controllers/OpenApiController.php create mode 100644 src/Docs/TokenSecurity.php create mode 100644 src/Docs/UnauthorizedResponse.php create mode 100644 src/Events/Dispatcher.php create mode 100644 src/Events/Listeners/Database/Connected.php create mode 100644 src/Events/Listeners/Listener.php create mode 100644 src/Events/Listeners/ListenerInterface.php create mode 100644 src/Events/Subscribers/Subscriber.php create mode 100644 src/Events/Subscribers/SubscriberInterface.php create mode 100644 src/Grpc.php create mode 100644 src/GrpcHandlers/GreeterHandler.php create mode 100644 src/Helpers/Env.php create mode 100644 src/Helpers/Ulid.php create mode 100644 src/Helpers/Version.php create mode 100644 src/Http/JsonResponseFactory.php create mode 100644 src/Http/Middleware/CorsMiddleware.php create mode 100644 src/Http/Middleware/JwtMiddleware.php create mode 100644 src/Http/Middleware/Middleware.php create mode 100644 src/Http/Middleware/ScopeMiddleware.php create mode 100644 src/Http/Responses/GenericResponse.php create mode 100644 src/Http/Responses/NotFoundResponse.php create mode 100644 src/Http/Responses/ServerErrorResponse.php create mode 100644 src/Kernel.php create mode 100644 src/Log/Logger.php create mode 100644 src/Models/Model.php create mode 100644 src/Models/User.php create mode 100644 src/Services/Facade.php create mode 100644 src/Services/Facades/Broker.php create mode 100644 src/Services/Facades/CommandBus.php create mode 100644 src/Services/Facades/Config.php create mode 100644 src/Services/Facades/Dispatcher.php create mode 100644 src/Services/Facades/Guzzle.php create mode 100644 src/Services/Facades/Logger.php create mode 100644 src/Services/Facades/Redis.php create mode 100644 src/Services/Facades/RoadRunnerLogger.php create mode 100644 src/Services/ServiceProviders/BrokerServiceProvider.php create mode 100644 src/Services/ServiceProviders/CommandBusProvider.php create mode 100644 src/Services/ServiceProviders/DispatcherServiceProvider.php create mode 100644 src/Services/ServiceProviders/LoggerServiceProvider.php create mode 100644 src/Services/ServiceProviders/RedisServiceProvider.php create mode 100644 tests/Attributes/Guards/JwtTest.php create mode 100644 tests/Attributes/Guards/ScopeTest.php create mode 100644 tests/Attributes/HandlesMessageTest.php create mode 100644 tests/CommandBus/AttributeLocatorTest.php create mode 100644 tests/CommandBus/Handlers/ExampleHandlerTest.php create mode 100644 tests/Controllers/AbstractController.php create mode 100644 tests/Controllers/ControllerTest.php create mode 100644 tests/Controllers/IndexControllerTest.php create mode 100644 tests/Controllers/OpenApiControllerTest.php create mode 100644 tests/Events/DispatcherTest.php create mode 100644 tests/Events/Listeners/ConnectedTest.php create mode 100644 tests/Facades/AbstractFacade.php create mode 100644 tests/Facades/DispatcherTest.php create mode 100644 tests/Facades/GuzzleTest.php create mode 100644 tests/Facades/LoggerTest.php create mode 100644 tests/Facades/RedisTest.php create mode 100644 tests/GrpcHandlers/GreeterHandlerTest.php create mode 100644 tests/Helpers/EnvTest.php create mode 100644 tests/Helpers/UlidTest.php create mode 100644 tests/Http/JsonResponseFactoryTest.php create mode 100644 tests/Http/Middleware/CorsMiddlewareTest.php create mode 100644 tests/Http/Middleware/JwtMiddlewareTest.php create mode 100644 tests/Http/Middleware/Middleware.php create mode 100644 tests/Http/Middleware/ScopeMiddlewareTest.php create mode 100644 tests/Http/Responses/NotFoundResponseTest.php create mode 100644 tests/Http/Responses/ServerErrorResponseTest.php create mode 100644 tests/Log/LoggerRpcTest.php create mode 100644 tests/Log/LoggerTest.php create mode 100644 tests/Models/UserTest.php create mode 100644 tests/ServiceProviders/AbstractServiceProvider.php create mode 100644 tests/ServiceProviders/CommandBusServiceProviderTest.php create mode 100644 tests/ServiceProviders/DispatcherServiceProviderTest.php create mode 100644 tests/ServiceProviders/LoggerServiceProviderTest.php create mode 100644 tests/ServiceProviders/RedisServiceProviderTest.php create mode 100644 tests/Unit.php diff --git a/.allowed-licenses.php b/.allowed-licenses.php new file mode 100644 index 0000000..5b60aa3 --- /dev/null +++ b/.allowed-licenses.php @@ -0,0 +1,14 @@ +addLicenses( + 'MIT', + 'BSD-2-Clause', + 'BSD-3-Clause', + 'Apache-2.0', + ) + ->build(); diff --git a/.dev/docker-compose.yml b/.dev/docker-compose.yml new file mode 100644 index 0000000..32f6ac5 --- /dev/null +++ b/.dev/docker-compose.yml @@ -0,0 +1,192 @@ +volumes: + redisdata: {} + pgdata: {} + +services: + + traefik: + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik.entrypoints=web-secure" + - "traefik.http.routers.traefik.rule=Host(`127.0.0.1`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))" + - "traefik.http.routers.traefik.tls=true" + - "traefik.http.routers.traefik.service=api@internal" + image: traefik:latest + container_name: traefik + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "80:80" + - "443:443" + - "9001:9001" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "./ssl:/etc/ssl" + restart: always + command: + - "--providers.docker=true" + - "--api.insecure=true" + - "--ping" + - "--providers.file.filename=/etc/ssl/traefik.yml" + - "--providers.docker.exposedByDefault=false" + - "--entrypoints.web.address=:80" + - "--entrypoints.web-secure.address=:443" + - "--entrypoints.grpc.address=:9001" + - "--accesslog=true" + - "--entrypoints.web.http.redirections.entryPoint.to=web-secure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--entrypoints.web.http.redirections.entrypoint.permanent=true" + + composer-runtime: + volumes: + - ..:/app + image: siteworxpro/composer + entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'" + environment: + PHP_IDE_CONFIG: serverName=localhost + + swagger-ui: + labels: + - "traefik.enable=true" + - "traefik.http.routers.swagger-ui.entrypoints=web-secure" + - "traefik.http.routers.swagger-ui.rule=Host(`localhost`) && PathPrefix(`/docs`)" + - "traefik.http.routers.swagger-ui.tls=true" + - "traefik.http.routers.swagger-ui.service=swagger-ui" + - "traefik.http.services.swagger-ui.loadbalancer.server.port=8080" + image: swaggerapi/swagger-ui:latest + container_name: swagger-ui + environment: + BASE_URL: /docs + URL: /.well-known/swagger.yaml + + migration-container: + volumes: + - ../db/migrations:/app/db/migrations + - ../bin:/app/bin + image: siteworxpro/migrate:v4.18.3 + working_dir: /app +# entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'" + entrypoint: /bin/sh -c '/app/bin/migrate.sh' + depends_on: + postgres: + condition: service_healthy + environment: + DB_USERNAME: ${DB_USERNAME:-siteworxpro} + DB_PASSWORD: ${DB_PASSWORD:-password} + DB_DATABASE: ${DB_DATABASE:-siteworxpro} + DB_HOST: ${DB_HOST-postgres} + DB_PORT: ${DB_PORT-5432} + + dev-runtime: + labels: + - "traefik.enable=true" + - "traefik.http.routers.api.entrypoints=web-secure" + - "traefik.http.routers.api.rule=Host(`localhost`) || Host(`127.0.0.1`)" + - "traefik.http.routers.api.tls=true" + - "traefik.http.routers.api.service=api" + - "traefik.http.services.api.loadbalancer.healthcheck.path=/healthz" + - "traefik.http.services.api.loadbalancer.healthcheck.interval=5s" + - "traefik.http.services.api.loadbalancer.healthcheck.timeout=60s" + - "traefik.tcp.services.api.loadbalancer.server.port=9001" + - "traefik.http.services.api.loadbalancer.server.port=9501" + - "traefik.tcp.routers.grpc.entrypoints=grpc" + - "traefik.tcp.routers.grpc.rule=HostSNI(`localhost`) || HostSNI(`127.0.0.1`)" + - "traefik.tcp.routers.grpc.tls=true" + - "traefik.tcp.routers.grpc.service=api" + container_name: dev-runtime + volumes: + - ..:/app + build: + args: + KAFKA_ENABLED: "0" + UID: 0 + USER: root + context: .. + dockerfile: Dockerfile + entrypoint: "/bin/sh -c 'while true; do sleep 30; done;'" + depends_on: + migration-container: + condition: service_completed_successfully + traefik: + condition: service_healthy + redis: + condition: service_healthy + postgres: + condition: service_healthy + environment: + JWT_ISSUER: https://auth.siteworxpro.com/application/o/postman/ + JWT_AUDIENCE: 1RWyqJFlyA4hmsDzq6kSxs0LXvk7UgEAfgmBCpQ9 + JWT_SIGNING_KEY: https://auth.siteworxpro.com/application/o/postman/.well-known/openid-configuration + QUEUE_BROKER: redis + PHP_IDE_CONFIG: serverName=localhost + WORKERS: 1 + GRPC_WORKERS: 1 + DEBUG: 1 + REDIS_HOST: redis + DB_HOST: postgres + DEV_MODE: 1 + + ## Kafka and Zookeeper for local development + kafka-ui: + image: kafbat/kafka-ui:latest # Or kafbat/kafka-ui:latest for newer Kafka + container_name: kafka-ui + ports: + - "8080:8080" # Expose the UI port + environment: + KAFKA_CLUSTERS_0_NAME: local-kafka-cluster + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + depends_on: + kafka: + condition: service_started + zookeeper: + condition: service_started + zookeeper: + image: ubuntu/zookeeper:latest + environment: + ALLOW_ANONYMOUS_LOGIN: "yes" + ports: + - "2181:2181" + kafka: + image: ubuntu/kafka:latest + environment: + KAFKA_BROKER_ID: 1 + KAFKA_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + ALLOW_PLAINTEXT_LISTENER: "yes" + ports: + - "9092:9092" + depends_on: + zookeeper: + condition: service_started + + redis: + image: redis:latest + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "6379:6379" + volumes: + - redisdata:/data + + postgres: + image: postgres:18 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-siteworxpro}"] + interval: 10s + timeout: 5s + retries: 5 + environment: + POSTGRES_USER: ${DB_USERNAME:-siteworxpro} + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + POSTGRES_DB: ${DB_DATABASE:-siteworxpro} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql \ No newline at end of file diff --git a/.dev/ssl/localhost.crt b/.dev/ssl/localhost.crt new file mode 100644 index 0000000..b6345ae --- /dev/null +++ b/.dev/ssl/localhost.crt @@ -0,0 +1,83 @@ +-----BEGIN CERTIFICATE----- +MIIEFzCCA52gAwIBAgIURfvF11Q9R3Ue38Tr0BzIoUe0TKQwCgYIKoZIzj0EAwMw +MDEuMCwGA1UEAxMlU2l0ZXdvcnggSW50ZXJtZWRpYXRlIEVDMzg0IEF1dGhvcml0 +eTAeFw0yNTEyMDQxNjM1NTFaFw0yNjEyMDQxNjM2MjFaMHExCzAJBgNVBAYTAlVT +MREwDwYDVQQIEwhWaXJnaW5pYTEVMBMGA1UEBxMMUHVyY2VsbHZpbGxlMSQwIgYD +VQQKExtTaXRld29yeCBQcm9mZXNzaW9uYWxzLCBMTEMxEjAQBgNVBAMTCWxvY2Fs +aG9zdDB2MBAGByqGSM49AgEGBSuBBAAiA2IABM+jXangYCOi01IMblAXJ6iFZE4v +SBBOZKNQCwGz8kKi5jyXtVwz6U26DMlBSK+InhhOFQlCRcP9ow8LtlQdaY2XnGKr +3X3zxdUZJVhLi/wog+I4igU3+xuyn1E/BgEZx6OCAjUwggIxMA4GA1UdDwEB/wQE +AwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW +BBRAtanjiWMYAdpCCz0rEkyqf691bzAfBgNVHSMEGDAWgBQYBC15lPoGGGxbmwqY +MWL7jjI6azBJBggrBgEFBQcBAQQ9MDswOQYIKwYBBQUHMAGGLWh0dHBzOi8vdmF1 +bHQuc2l0ZXdvcnhwcm8uY29tL3YxL3N3eF9pbnQvb2NzcDAaBgNVHREEEzARggls +b2NhbGhvc3SHBH8AAAEwHAYDVR0gBBUwEzAIBgZngQwBAgIwBwYFZ4EMAQEwggE1 +BgNVHR8EggEsMIIBKDBioGCgXoZcaHR0cHM6Ly92YXVsdC5zaXRld29yeHByby5j +b20vdjEvc3d4X2ludC9pc3N1ZXIvMjVmMWRiNTAtZDQxOS1kZWQ3LTZiZjktZWNh +Y2E4NGEwMmY0L2NybC9wZW0wXqBcoFqGWGh0dHBzOi8vdmF1bHQuc2l0ZXdvcnhw +cm8uY29tL3YxL3N3eF9pbnQvaXNzdWVyLzI1ZjFkYjUwLWQ0MTktZGVkNy02YmY5 +LWVjYWNhODRhMDJmNC9jcmwwYqBgoF6GXGh0dHBzOi8vdmF1bHQuc2l0ZXdvcnhw +cm8uY29tL3YxL3N3eF9pbnQvaXNzdWVyLzI1ZjFkYjUwLWQ0MTktZGVkNy02YmY5 +LWVjYWNhODRhMDJmNC9jcmwvZGVyMAoGCCqGSM49BAMDA2gAMGUCMGxgZmKITQFu +H6j3j/t9MOTxhVsfOuoD0q3pMlp9d1u4Lg0THKUOzN06BVuXwC1eagIxAL2I/2a1 +MMJmhky2EavzOsYt37Ae+1KGyELiwcWe5f/lActlw97pqRajpmqEmdo7PA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEETCCAfmgAwIBAgIUIRpRFzFBITweYJETytgbPBgwbWgwDQYJKoZIhvcNAQEL +BQAwgZ0xCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhWaXJnaW5pYTEVMBMGA1UEBwwM +UHVyY2VsbHZpbGxlMSQwIgYDVQQKDBtTaXRld29yeCBQcm9mZXNzaW9uYWxzLCBM +TEMxFDASBgNVBAMMC1NXWCBSb290IENBMSgwJgYJKoZIhvcNAQkBFhl3ZWJtYXN0 +ZXJAc2l0ZXdvcnhwcm8uY29tMB4XDTIzMDMyMTE2MzAxNVoXDTMzMDMxODE2MzA0 +NVowMDEuMCwGA1UEAxMlU2l0ZXdvcnggSW50ZXJtZWRpYXRlIEVDMzg0IEF1dGhv +cml0eTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIhlP1W1O1WjoDFGFi5XbE0zVy90 +76pQQ8VmSYtaZI9Jz5pAZTOQ073t/QkTWge8uhDaJ2J2uBhjQJGr5BPttvBcLJFI +52X7hJuck4oL0aukXiHYA5gZbC5LhKVvCyZcWqNjMGEwDgYDVR0PAQH/BAQDAgEG +MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFBgELXmU+gYYbFubCpgxYvuOMjpr +MB8GA1UdIwQYMBaAFHWCysIFrdsWYZJSjBO1pSQPETkTMA0GCSqGSIb3DQEBCwUA +A4ICAQBbw5roegt0tUc+gu0IcHDt56cUoqChmIZXzla8gTgg820ww/+Wm+vNAl8W +r3Y67LzK19CygoujD2o7M25syaByRiw9JdIfNGvBzklOOM+sus9DDmwSUBMCuljS +KLBhWzIrXDZwemzklGEbj+RL4o2ZiL01nx8xygDF55eaudNS0VzRzd2Hv0C+rm2i +nnwRNoKsL14YXc41rFBWwb5ViRuD2Wp0c9CivEOd4UNKgOnGyNxcNhjzNlY05t3c +NEeskEXiz21sj0vnrwM7olKyXPXDFUCCKGb21Sn9sWKldicumU1i1HdDGA1w50uh +NS4G4wqGQ8iZCq3h6JkpBMGPJPG3Dq6yuzrh8fmh56IqtKY4MxdKHb91MtFHnkw5 +jCrxqpTKShRyqcBSx8QmXRXpec5FEB88NQ3aKhtFlNqXYphNRAI9bLIyGkdxUF/r +PCkZkKBhbsRvXT8Ii/K1PQHzliQqJxXhrrJEsIg2jiSQItBg52ZySzuw+Y6++h11 +73XMKJ53oOeLcxvp2qJRwMkNTwVfNxDmKC0tIRdI+KoJYbYeN0Ev/pEdPdYl+hjY +uQhKMt1KtpUyYwPzTGPKGMnklKj/T3Qu7fmpsWxtAOuK7yLLMayBwXBlVBD23md+ +UAfPR3FfVX+aRqqsvT7WI+SnlycJuYXs41ZPxBjLq2aB7fhAwQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGIjCCBAqgAwIBAgIJAKU+Idu5bncNMA0GCSqGSIb3DQEBCwUAMIGdMQswCQYD +VQQGEwJVUzERMA8GA1UECAwIVmlyZ2luaWExFTATBgNVBAcMDFB1cmNlbGx2aWxs +ZTEkMCIGA1UECgwbU2l0ZXdvcnggUHJvZmVzc2lvbmFscywgTExDMRQwEgYDVQQD +DAtTV1ggUm9vdCBDQTEoMCYGCSqGSIb3DQEJARYZd2VibWFzdGVyQHNpdGV3b3J4 +cHJvLmNvbTAeFw0yMDA5MDgxMjU3NTJaFw00MDA5MDMxMjU3NTJaMIGdMQswCQYD +VQQGEwJVUzERMA8GA1UECAwIVmlyZ2luaWExFTATBgNVBAcMDFB1cmNlbGx2aWxs +ZTEkMCIGA1UECgwbU2l0ZXdvcnggUHJvZmVzc2lvbmFscywgTExDMRQwEgYDVQQD +DAtTV1ggUm9vdCBDQTEoMCYGCSqGSIb3DQEJARYZd2VibWFzdGVyQHNpdGV3b3J4 +cHJvLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAN9JNyWot7VX +ODvru8S5/o6gdFuynA1l5T0uXSzWhROMYHndmY+n7pwQCwf1R8iLL3aat9sDRxqM +RRScD3nNW6UzC5xNz12wiuemf2KT82cTjmUBU3CvtjstbgkrQ/SrpR/Arvu2YwUe +tmL9ft/xaoGvZXx8LKpyRMrHA1FlS2st+RFWBC0yXTU/nL4/7YQKVEcbc3YZvgCT +P4/8pxH9u8W7kgnufQHHKEIZR9lxIUhQ7yvc61B3zMntbJsZV1N+0c7j5DXY5cfT +6zXlfG2hSX1dbhM56y8O8KiCFaWaDRZ9mwkfZGM0W58gkhXUPXOrIOwewLmvl2Z4 +Vu43UkLfKhtQApxk6zodHRq1e2rNWSpBCGznT9XyoeO/spJ7yggNkleTa+SnnlmV +rHJS/YUp3/jAvJY2bCHQKFu/mguMY3Ub2X6eEBsVZOmUqDMbya1TPP6GCVqh4gUu +yip6qS9UksaTF6IN3IcrGhwtTyvp8BFqwVA0tMhgraf1rv6ZoXjY/NDuGjE1xXJg +Hg+gg2pIIRcXjcsG1tXFXTgxDqoh127ADg/gtq9cIyarMx4LdNTjnR+CnhjqvRkT +uiUBB1bwDc9pbX0ulfnR+VuIZtQ6PSuWwChnMdNBKmCgQT1J1AHWpQqnFTjg42NV +5QAdFOQxAnsq2DxkurVFEz2J3euZx1ZdAgMBAAGjYzBhMB0GA1UdDgQWBBR1gsrC +Ba3bFmGSUowTtaUkDxE5EzAfBgNVHSMEGDAWgBR1gsrCBa3bFmGSUowTtaUkDxE5 +EzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAZDjIlaAoMfRGdb3/i40s6nN18iWN2Chttd8dLXgV+/SZ8GrAU89JNJrK +ODaLZT1wHeWVz0LP3miByuvfrnH4qzPEOI2L6zEy/FJr8SCivjm7aUExyb5kTSXp +LkwVcOI9UfQb6lCy9Gs/rUEcWQjs5KS3dy6ZwBMaywq6sRj7MeXmhqXhj7aAyWFA +psnQsuP2XweWa9OX6Z+u78sebfoiJlOEUvV9VRNHQYpLUd75p6sti1Dm9blWkZEO +hyssi3kOJMH+g5pc9xNbD9gS+/pFUWxEVAhHOc0xdEIcHfV5oiiOUDD5EOIPi3xv +/NYTV7o7pv2/QlH09vO2PHdsy07lhsg7NoM3U+zYq609Ox78/b4PNd+TkdtYKebO +VumZ0xXab0lWbTVuno52k473ODQRA/v9YWHtuovW0Lzf5fDcBhVXTDeW21SmMJIx +B+dgJDh7ql7ruZqjMj+kePjM9Mm+M5pDZ6vrEtgiR2yQj/IE+LoQh/bxFHpFkIK8 +I6AWoxABAvLZB+KHl1ufR5yOauJG2+SQRuzHNZvkAcdjmwpgfxcsB2mY7o0RbGmZ +VWm97P4P9iJhje/W4C0cGwVY5wRAMAg6SI1BpcW7YghB14UrKaxpEzHCdZIeeT94 +GYzN2XNSSGW3s1anFedd5PQyRM7PlJIcloLYrqyWW6M7OwWnMXA= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/.dev/ssl/localhost.key b/.dev/ssl/localhost.key new file mode 100644 index 0000000..fd525ca --- /dev/null +++ b/.dev/ssl/localhost.key @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBrpJYaCMqgu490fpZoIphGVspE33v3JwyD9B55HwSX/jykySs9NTOv +68YndzE9LNCgBwYFK4EEACKhZANiAATPo12p4GAjotNSDG5QFyeohWROL0gQTmSj +UAsBs/JCouY8l7VcM+lNugzJQUiviJ4YThUJQkXD/aMPC7ZUHWmNl5xiq91988XV +GSVYS4v8KIPiOIoFN/sbsp9RPwYBGcc= +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/.dev/ssl/traefik.yml b/.dev/ssl/traefik.yml new file mode 100644 index 0000000..04883da --- /dev/null +++ b/.dev/ssl/traefik.yml @@ -0,0 +1,14 @@ +tls: + stores: + default: + defaultCertificate: + certFile: /etc/ssl/localhost.crt + keyFile: /etc/ssl/localhost.key + + options: + default: + minVersion: VersionTLS13 + preferServerCipherSuites: true + + mintls13: + minVersion: VersionTLS13 \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..703fc3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.idea/ +.DS_Store +vendor/ +.phpunit.cache/ +tests/ \ No newline at end of file diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..31c19b5 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,79 @@ +on: + push: + tags: + - "v*" + +name: ๐Ÿ—๏ธโœจ Build Workflow + +jobs: + Build: + name: ๐Ÿ–ฅ๏ธ ๐Ÿ”จ Build + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Write Version File + run: | + echo $GITEA_REF_NAME > VERSION + sed -i "s/dev-version/${GITEA_REF_NAME}/g" src/Helpers/Version.php + + + - name: ๐Ÿ—๏ธ ๐Ÿ”ง Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: ๐Ÿณ ๐Ÿ”จ Build Container + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + dockerfile: Dockerfile + tags: siteworxpro/template:${{ gitea.ref_name }} + + Build-Migrations: + needs: + - Build + name: ๐Ÿ–ฅ๏ธ ๐Ÿ”จ Build Migrations + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ—๏ธ ๐Ÿ”ง Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: ๐Ÿณ ๐Ÿ”จ Build Migrations Container + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + sbom: true + provenance: true + context: . + file: migrations.Dockerfile + tags: siteworxpro/template:${{ gitea.ref_name }}-migrations \ No newline at end of file diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..b2f162f --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,268 @@ +on: + push: + branches: + - "**" + +name: ๐Ÿงชโœจ Tests Workflow + +jobs: + + DatabaseMigrations: + name: ๐Ÿงช โœจ Database Migrations + runs-on: ubuntu-latest + steps: + + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐ŸŽ๏ธ ๐Ÿ Start Support Containers + run: | + echo "Starting Support Containers" + docker run --rm \ + --network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \ + --name ${{ gitea.job }}-${{ gitea.run_id }}-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=postgres \ + -p 5432 \ + -d postgres:18 + + echo "Waiting for Postgres to start" + sleep 10 + + - name: ๐Ÿ’ฝ โฌ†๏ธ Run Migrations + run: | + docker run \ + --name ${{ gitea.run_id }}-migrate \ + --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + --network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \ + -w ${{ github.workspace }} \ + siteworxpro/migrate:v4.18.3 -database "postgres://postgres:postgres@${{ gitea.job }}-${{ gitea.run_id }}-postgres:5432/postgres?sslmode=disable" -path db/migrations up + + - name: ๐Ÿ’ฝ โฌ‡๏ธ Rollback Migrations + run: | + docker run \ + --name ${{ gitea.run_id }}-migrate \ + --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + --network "${{ env.JOB_CONTAINER_NAME }}-${{ gitea.job }}-network" \ + -w ${{ github.workspace }} \ + siteworxpro/migrate:v4.18.3 -database "postgres://postgres:postgres@${{ gitea.job }}-${{ gitea.run_id }}-postgres:5432/postgres?sslmode=disable" -path db/migrations down --all + + - name: ๐Ÿงจ ๐Ÿ’ฅ Tear Down Support Containers + if: always() + run: | + docker stop ${{ gitea.job }}-${{ gitea.run_id }}-postgres + + LibraryAudit: + name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Library Audit + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– โœจ Install Composer Libraries + run: | + docker run --rm \ + -v composer-cache:/tmp/cache \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Library Audit + run: | + docker run --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + audit + + LicenseCheck: + name: ๐Ÿ›ก๏ธ ๐Ÿ”’ License Check + runs-on: ubuntu-latest + steps: + + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– โœจ Install Composer Libraries + run: | + docker run --rm \ + -v composer-cache:/tmp/cache \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader + + - name: ๐ŸŽŸ๏ธ ๐Ÿ”ฌ Run License Check + run: | + docker run --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + run tests:license + + CodeLint: + name: ๐Ÿ“ โœจ Code Lint + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– โœจ Install Composer Libraries + run: | + docker run --rm \ + -v composer-cache:/tmp/cache \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Code Lint + run: | + docker run --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + run tests:lint + + CodeSniffer: + name: ๐Ÿ™ ๐Ÿ” Code Sniffer + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– โœจ Install Composer Libraries + run: | + docker run --rm \ + -v composer-cache:/tmp/cache \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader + + - name: Run Code Sniffer + run: | + docker run --rm \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + run tests:phpstan + + UnitTests: + name: ๐Ÿงช โœ… Unit Tests + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›ก๏ธ ๐Ÿ”’ Add Siteworx CA Certificates + run: | + curl -Ls https://siteworxpro.com/hosted/Siteworx+Root+CA.pem -o /usr/local/share/ca-certificates/sw.crt + update-ca-certificates + + - name: ๐Ÿ“– ๐Ÿ” Checkout Repository Code + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: ๐Ÿ”‘ ๐Ÿ” Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: ๐Ÿ“– โœจ Install Composer Libraries + run: | + docker run --rm \ + -v composer-cache:/tmp/cache \ + --volumes-from ${{ env.JOB_CONTAINER_NAME }} \ + -w ${{ github.workspace }} \ + siteworxpro/composer \ + install --ignore-platform-reqs --no-interaction --prefer-dist --optimize-autoloader + + - name: ๐Ÿงช โœ… Run Unit Tests + uses: addnab/docker-run-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + image: siteworxpro/composer + options: --volumes-from ${{ env.JOB_CONTAINER_NAME }} -w ${{ gitea.workspace }} + run: | + bin/pcov.sh + composer run tests:unit:coverage + +# - name: ๐Ÿ“ฆ Publish Build Artifacts +# env: +# NODE_TLS_REJECT_UNAUTHORIZED: 0 +# uses: christopherhx/gitea-upload-artifact@v4 +# with: +# options: --volumes-from ${{ env.JOB_CONTAINER_NAME }} -w ${{ gitea.workspace }} +# name: junit-coverage.xml +# path: tests/reports/junit.xml +# retention-days: 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1a6129 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.DS_Store +vendor/ +.phpunit.cache/ + +tests/reports/ \ No newline at end of file diff --git a/.rr.yaml b/.rr.yaml new file mode 100644 index 0000000..180c970 --- /dev/null +++ b/.rr.yaml @@ -0,0 +1,35 @@ +version: "3" + +server: + command: "php server.php" + +rpc: + listen: tcp://127.0.0.1:6001 + +grpc: + listen: "tcp://0.0.0.0:9001" + pool: + command: "php grpc-worker.php" + num_workers: ${GRPC_WORKERS:-4} + debug: ${DEBUG:-false} + reflection: ${GRPC_REFLECTION:-true} + destroy_timeout: 5s + proto: + - "protos/example.proto" + +http: + pool: + allocate_timeout: 5s + reset_timeout: 5s + destroy_timeout: 5s + stream_timeout: 5s + num_workers: ${WORKERS:-4} + debug: ${DEBUG:-false} + + address: 0.0.0.0:${HTTP_PORT:-9501} + access_logs: ${ACCESS_LOGS:-true} + +logs: + encoding: json + level: ${LOG_LEVEL:-info} + mode: ${LOG_MODE:-production} \ No newline at end of file diff --git a/.run/ Compose Deployment.run.xml b/.run/ Compose Deployment.run.xml new file mode 100644 index 0000000..f5ff961 --- /dev/null +++ b/.run/ Compose Deployment.run.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.run/All.run.xml b/.run/All.run.xml new file mode 100644 index 0000000..77c7e80 --- /dev/null +++ b/.run/All.run.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.run/Lint_fix.run.xml b/.run/Lint_fix.run.xml new file mode 100644 index 0000000..06b7491 --- /dev/null +++ b/.run/Lint_fix.run.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.run/Main.run.xml b/.run/Main.run.xml new file mode 100644 index 0000000..b79a586 --- /dev/null +++ b/.run/Main.run.xml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6edb7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Use the RoadRunner image as a base for the first stage +FROM ghcr.io/roadrunner-server/roadrunner:2025.1.6 AS roadrunner + +# Use the official Composer image as the base for the library stage +FROM siteworxpro/composer AS library + +# Add Composer configuration files to the working directory +ADD composer.json composer.lock ./ + +# Install PHP dependencies, ignoring platform requirements and excluding development dependencies +RUN composer install --optimize-autoloader --ignore-platform-reqs --no-dev + + +# Use the official PHP CLI image with Alpine Linux for the second stage +FROM siteworxpro/php:8.5.1-cli-alpine AS php + +ARG KAFKA_ENABLED=0 +ARG USER=appuser +ARG UID=1000 + +# Move the production PHP configuration file to the default location +RUN mv /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini \ + && apk add libpq-dev linux-headers --no-cache \ + && docker-php-ext-install pdo_pgsql sockets pcntl \ + && rm -rf /var/cache/apk/* + +RUN if [ "$KAFKA_ENABLED" -eq 1 ] ; then \ + echo "Kafka support enabled" ; \ + apk add autoconf g++ librdkafka-dev make --no-cache ; \ + pecl install rdkafka && docker-php-ext-enable rdkafka ; \ + apk del autoconf g++ make ; \ + else \ + echo "Kafka support disabled" ; \ + exit 0 ; \ + fi + +# Set the working directory to /app +WORKDIR /app + +# Copy the RoadRunner binary from the first stage to the second stage +COPY --from=roadrunner /usr/bin/rr /usr/local/bin/rr + +# Copy the installed PHP dependencies from the library stage +COPY --from=library /app/vendor /app/vendor + +# Copy the RoadRunner configuration file and source +ADD src src/ +ADD generated generated/ +ADD protos protos/ +ADD server.php cli.php grpc-worker.php .rr.yaml config.php ./ + +EXPOSE 9501 9001 + +# Create a non-root user and set ownership of the /app directory +RUN if [ ! $UID -eq 0 ] ; then addgroup -g $UID $USER && adduser -D -u $UID -G $USER $USER && chown -R $USER:$USER /app ; fi +USER $USER + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD curl -f http://localhost:9501/healthz || exit 1 + +# Entrypoint command to run the RoadRunner server with the specified configuration +ENTRYPOINT ["rr", "serve"] +CMD ["-c", ".rr.yaml", "-s"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..072f710 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# Template + +![pipeline status](https://gitea.siteworxpro.com/siteworxpro/Php-Template/actions/workflows/tests.yml/badge.svg?branch=master&style=flat-square) + +## Overview + +This is a PHP project template that provides a structured development environment using Docker Compose and Make. +It includes tools for code quality, testing, dependency management, and gRPC support. + +## Dev Environment + +This project uses Docker Compose and Make to manage the development environment. The `makefile` provides convenient +commands for common development tasks. + +## Prerequisites + +- Docker and Docker Compose +- Make +- protoc (Protocol Buffers compiler) - for gRPC code generation + +## Quick Start + +```bash +# Install PHP dependencies +make composer-install + +# Start the development container +make start + +# Run the application server +make run +``` + +## Available Commands + +### Container Management + +- `make start` - Start the development runtime container +- `make stop` - Stop and remove all containers +- `make restart` - Restart the development container +- `make rebuild` - Rebuild containers (use after Dockerfile changes) +- `make sh` - Open a shell in the development container +- `make ps` - Show running containers + +### Application + +- `make run` - Run the application server (RoadRunner) +- `make migrate` - Run database migrations + +### Composer & Dependencies + +- `make composer-install` - Install PHP dependencies +- `make composer-update` - Update PHP dependencies +- `make composer-require package=vendor/package` - Add a new dependency +- `make composer-require-dev package=vendor/package` - Add a new dev dependency + +### Code Quality + +- `make lint` - Run linters (phpcs and phpstan) +- `make fmt` - Format code with php-cs-fixer +- `make test` - Run the test suite (phpunit) +- `make license-check` - Check license headers in source files + +### Debugging & Coverage + +- `make enable-debug` - Enable Xdebug for debugging +- `make enable-coverage` - Enable PCOV for code coverage + +### gRPC + +- `make protoc` - Generate PHP gRPC code from `.proto` files + +### CI Workflow + +- `make ci` - Run the full CI pipeline locally (install, license check, lint, test) + +### Help + +- `make help` - Show all available commands with descriptions + +## Common Workflows + +### Starting Development + +```bash +make composer-install +make start +make run +``` + +### Adding a New Package + +```bash +make composer-require package=vendor/package-name +``` + +### Running Tests + +```bash +make test +``` + +### Code Quality Check + +```bash +make lint +make fmt +``` + +### Debugging + +```bash +make enable-debug +make run +``` + +## Notes + +- All commands run inside Docker containers, ensuring a consistent environment +- The development runtime uses RoadRunner as the application server +- Composer commands run in a separate `composer-runtime` container +- Database migrations run in a dedicated `migration-container` + +## Additional Information + +### Accessing Services + +- You can access the api at [https://localhost](https://localhost) +- Traefik dashboard is at [https://127.0.0.1/dashboard/](https://127.0.0.1/dashboard/) +- the grpc server is at [tcp://localhost:9001](tcp://localhost:9001) + +## License + +```text +Copyright (c)2025 Siteworx Professionals, LLC + +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. +``` \ No newline at end of file diff --git a/bin/migrate.sh b/bin/migrate.sh new file mode 100755 index 0000000..c39f90f --- /dev/null +++ b/bin/migrate.sh @@ -0,0 +1,4 @@ +eval #!/bin/sh +set -e + +migrate -path /app/db/migrations -database "postgres://$DB_USERNAME:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_DATABASE?sslmode=disable" up \ No newline at end of file diff --git a/bin/pcov.sh b/bin/pcov.sh new file mode 100755 index 0000000..3c149a8 --- /dev/null +++ b/bin/pcov.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2086 +apk --no-cache add pcre-dev ${PHPIZE_DEPS} + +git clone https://github.com/krakjoe/pcov.git +cd pcov || exec +phpize +./configure --enable-pcov +make +make install + +echo "extension=pcov.so" > /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini +echo "pcov.enabled=1" >> /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini +echo "pcov.directory=." >> /usr/local/etc/php/conf.d/docker-php-ext-pcov.ini + +# Cleanup +cd .. +rm -rf pcov diff --git a/bin/xdebug.sh b/bin/xdebug.sh new file mode 100755 index 0000000..dbeca36 --- /dev/null +++ b/bin/xdebug.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh + +echo "Installing xDebug" + +apk add make gcc linux-headers autoconf alpine-sdk + +curl -sL https://github.com/xdebug/xdebug/archive/3.5.0alpha3.tar.gz -o 3.5.0alpha3.tar.gz +tar -xvf 3.5.0alpha3.tar.gz +cd xdebug-3.5.0alpha3 || exit +phpize +./configure --enable-xdebug +make +make install + +echo " +zend_extension=xdebug.so +xdebug.mode=debug +xdebug.start_with_request = yes +xdebug.client_host = host.docker.internal +" > /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +cd .. +rm -rf xdebug-3.5.0alpha3 +rm -rf 3.5.0alpha3.tar.gz diff --git a/cli.php b/cli.php new file mode 100755 index 0000000..e6ea1f2 --- /dev/null +++ b/cli.php @@ -0,0 +1,11 @@ +#!/usr/local/bin/php +run()); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5abe47a --- /dev/null +++ b/composer.json @@ -0,0 +1,75 @@ +{ + "name": "siteworxpro/app", + "type": "project", + "autoload": { + "psr-4": { + "Siteworxpro\\App\\": "src/", + "Siteworxpro\\Tests\\": "tests/", + "GRPC\\": "generated/GRPC" + } + }, + "require": { + "php": "^8.5", + "league/route": "^6.2.0", + "illuminate/database": "^v12.34.0", + "spiral/roadrunner-http": "^v3.6.0", + "nyholm/psr7": "^1.8.2", + "illuminate/support": "^v12.10.2", + "roadrunner-php/app-logger": "^1.2.0", + "siteworxpro/config": "^1.1.1", + "predis/predis": "^v3.2.0", + "siteworxpro/http-status": "0.0.2", + "lcobucci/jwt": "^5.6", + "adhocore/cli": "^1.9", + "robinvdvleuten/ulid": "^5.0", + "monolog/monolog": "^3.9", + "react/promise": "^3", + "react/async": "^4", + "guzzlehttp/guzzle": "^7.10", + "zircote/swagger-php": "^5.7", + "spiral/roadrunner-grpc": "^3.5", + "league/tactician": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.4", + "mockery/mockery": "^1.6", + "squizlabs/php_codesniffer": "^4.0", + "lendable/composer-license-checker": "^1.2", + "phpstan/phpstan": "^2.1.31", + "kwn/php-rdkafka-stubs": "^2.2" + }, + "scripts": { + "tests:all": [ + "composer run-script tests:unit", + "composer run-script tests:lint", + "composer run-script tests:license", + "composer run-script tests:phpstan" + ], + "tests:unit": [ + "phpunit --colors=always --display-deprecations tests" + ], + "tests:unit:coverage": [ + "phpunit --coverage-text --colors=never --display-deprecations --log-junit tests/reports/junit.xml --coverage-html tests/reports/html tests " + ], + "tests:lint": [ + "phpcs ./src/**/*.php --standard=PSR12 --colors -v", + "phpcs ./tests/**/*.php --standard=PSR12 --colors -v" + ], + "tests:lint:fix": [ + "phpcbf ./src/**/*.php --standard=PSR12 --colors -v", + "phpcbf ./tests/**/*.php --standard=PSR12 --colors -v" + ], + "tests:license": [ + "composer-license-checker" + ], + "tests:phpstan": [ + "phpstan analyse --level 4 ./src/ -c phpstan.neon" + ] + }, + "repositories": [ + { + "type": "composer", + "url": "https://gitea.siteworxpro.com/api/packages/php-packages/composer" + } + ] +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..93797e9 --- /dev/null +++ b/composer.lock @@ -0,0 +1,6851 @@ +{ + "_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": "d027bee8e875c5542f7ff9612bfac4e2", + "packages": [ + { + "name": "adhocore/cli", + "version": "v1.9.4", + "source": { + "type": "git", + "url": "https://github.com/adhocore/php-cli.git", + "reference": "474dc3d7ab139796be98b104d891476e3916b6f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/adhocore/php-cli/zipball/474dc3d7ab139796be98b104d891476e3916b6f4", + "reference": "474dc3d7ab139796be98b104d891476e3916b6f4", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ahc\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jitendra Adhikari", + "email": "jiten.adhikary@gmail.com" + } + ], + "description": "Command line interface library for PHP", + "keywords": [ + "argument-parser", + "argv-parser", + "cli", + "cli-action", + "cli-app", + "cli-color", + "cli-option", + "cli-writer", + "command", + "console", + "console-app", + "php-cli", + "php8", + "stream-input", + "stream-output" + ], + "support": { + "issues": "https://github.com/adhocore/php-cli/issues", + "source": "https://github.com/adhocore/php-cli/tree/v1.9.4" + }, + "funding": [ + { + "url": "https://paypal.me/ji10", + "type": "custom" + }, + { + "url": "https://github.com/adhocore", + "type": "github" + } + ], + "time": "2025-05-11T13:23:54+00:00" + }, + { + "name": "brick/math", + "version": "0.14.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "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.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-08-29T12:40:03+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "google/common-protos", + "version": "4.12.4", + "source": { + "type": "git", + "url": "https://github.com/googleapis/common-protos-php.git", + "reference": "0127156899af0df2681bd42024c60bd5360d64e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/common-protos-php/zipball/0127156899af0df2681bd42024c60bd5360d64e3", + "reference": "0127156899af0df2681bd42024c60bd5360d64e3", + "shasum": "" + }, + "require": { + "google/protobuf": "^4.31", + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "extra": { + "component": { + "id": "common-protos", + "path": "CommonProtos", + "entry": "README.md", + "target": "googleapis/common-protos-php.git" + } + }, + "autoload": { + "psr-4": { + "Google\\Api\\": "src/Api", + "Google\\Iam\\": "src/Iam", + "Google\\Rpc\\": "src/Rpc", + "Google\\Type\\": "src/Type", + "Google\\Cloud\\": "src/Cloud", + "GPBMetadata\\Google\\Api\\": "metadata/Api", + "GPBMetadata\\Google\\Iam\\": "metadata/Iam", + "GPBMetadata\\Google\\Rpc\\": "metadata/Rpc", + "GPBMetadata\\Google\\Type\\": "metadata/Type", + "GPBMetadata\\Google\\Cloud\\": "metadata/Cloud", + "GPBMetadata\\Google\\Logging\\": "metadata/Logging" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "description": "Google API Common Protos for PHP", + "homepage": "https://github.com/googleapis/common-protos-php", + "keywords": [ + "google" + ], + "support": { + "source": "https://github.com/googleapis/common-protos-php/tree/v4.12.4" + }, + "time": "2025-09-20T01:29:44+00:00" + }, + { + "name": "google/protobuf", + "version": "v4.33.1", + "source": { + "type": "git", + "url": "https://github.com/protocolbuffers/protobuf-php.git", + "reference": "0cd73ccf0cd26c3e72299cce1ea6144091a57e12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/0cd73ccf0cd26c3e72299cce1ea6144091a57e12", + "reference": "0cd73ccf0cd26c3e72299cce1ea6144091a57e12", + "shasum": "" + }, + "require": { + "php": ">=8.1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=5.0.0 <8.5.27" + }, + "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.1" + }, + "time": "2025-11-12T21:58:05+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Mรกrk Sรกgi-Kazรกr", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Mรกrk Sรกgi-Kazรกr", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Mรกrk Sรกgi-Kazรกr", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "illuminate/collections", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "deb291b109b6f7fd776a3550a120771137b3c5d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/deb291b109b6f7fd776a3550a120771137b3c5d1", + "reference": "deb291b109b6f7fd776a3550a120771137b3c5d1", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "php": "^8.2", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^12.0).", + "symfony/var-dumper": "Required to use the dump method (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-10-30T12:22:05+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "ec677967c1f2faf90b8428919124d2184a4c9b49" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/ec677967c1f2faf90b8428919124d2184a4c9b49", + "reference": "ec677967c1f2faf90b8428919124d2184a4c9b49", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-05-13T15:08:45+00:00" + }, + { + "name": "illuminate/container", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/container.git", + "reference": "d6eaa8afd48dbe16b6b3c412a87479cad67eeb12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/container/zipball/d6eaa8afd48dbe16b6b3c412a87479cad67eeb12", + "reference": "d6eaa8afd48dbe16b6b3c412a87479cad67eeb12", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^12.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0" + }, + "suggest": { + "illuminate/auth": "Required to use the Auth attribute", + "illuminate/cache": "Required to use the Cache attribute", + "illuminate/config": "Required to use the Config attribute", + "illuminate/database": "Required to use the DB attribute", + "illuminate/filesystem": "Required to use the Storage attribute", + "illuminate/log": "Required to use the Log or Context attributes" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-09-12T14:35:11+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "5ab717c8f0dd4e84be703796bbb415ccff8de57a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/5ab717c8f0dd4e84be703796bbb415ccff8de57a", + "reference": "5ab717c8f0dd4e84be703796bbb415ccff8de57a", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-10-07T19:59:08+00:00" + }, + { + "name": "illuminate/database", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/database.git", + "reference": "eacbdddf31f655fba5406fdf31bd264d880dd1a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/database/zipball/eacbdddf31f655fba5406fdf31bd264d880dd1a8", + "reference": "eacbdddf31f655fba5406fdf31bd264d880dd1a8", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "ext-pdo": "*", + "illuminate/collections": "^12.0", + "illuminate/container": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "illuminate/support": "^12.0", + "laravel/serializable-closure": "^1.3|^2.0", + "php": "^8.2", + "symfony/polyfill-php85": "^1.33" + }, + "suggest": { + "ext-filter": "Required to use the Postgres database driver.", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.24).", + "illuminate/console": "Required to use the database commands (^12.0).", + "illuminate/events": "Required to use the observers with Eloquent (^12.0).", + "illuminate/filesystem": "Required to use the migrations (^12.0).", + "illuminate/http": "Required to convert Eloquent models to API resources (^12.0).", + "illuminate/pagination": "Required to paginate the result set (^12.0).", + "symfony/finder": "Required to use Eloquent model factories (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Database\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Database package.", + "homepage": "https://laravel.com", + "keywords": [ + "database", + "laravel", + "orm", + "sql" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-11-11T14:13:21+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "e862e5648ee34004fa56046b746f490dfa86c613" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/e862e5648ee34004fa56046b746f490dfa86c613", + "reference": "e862e5648ee34004fa56046b746f490dfa86c613", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2024-07-23T16:31:01+00:00" + }, + { + "name": "illuminate/support", + "version": "v12.38.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "008b6c0d45f548de0f801d60a5854a7f9e4dd32f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/008b6c0d45f548de0f801d60a5854a7f9e4dd32f", + "reference": "008b6c0d45f548de0f801d60a5854a7f9e4dd32f", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^12.0", + "illuminate/conditionable": "^12.0", + "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php85": "^1.33", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^12.0).", + "laravel/serializable-closure": "Required to use the once function (^1.3|^2.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.2).", + "symfony/uid": "Required to use Str::ulid() (^7.2).", + "symfony/var-dumper": "Required to use the dd function (^7.2).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-11-06T14:27:18+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "038ce42edee619599a1debb7e81d7b3759492819" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819", + "reference": "038ce42edee619599a1debb7e81d7b3759492819", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-10-09T13:42:30+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luรญs Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "league/route", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/route.git", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/route/zipball/38775ed32d49ff1ce98d88adaa06a8d66b923436", + "reference": "38775ed32d49ff1ce98d88adaa06a8d66b923436", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^2.0.0", + "nikic/fast-route": "^1.3", + "php": "^8.1", + "psr/container": "^2.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0", + "psr/http-server-handler": "^1.0.1", + "psr/http-server-middleware": "^1.0.1", + "psr/simple-cache": "^3.0" + }, + "replace": { + "orno/http": "~1.0", + "orno/route": "~1.0" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^10.2", + "roave/security-advisories": "dev-latest", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev", + "dev-3.x": "3.x-dev", + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev", + "dev-6.x": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Route\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phil Bennett", + "email": "mail@philbennett.co.uk", + "role": "Developer" + } + ], + "description": "Fast routing and dispatch component including PSR-15 middleware, built on top of FastRoute.", + "homepage": "https://github.com/thephpleague/route", + "keywords": [ + "dispatcher", + "league", + "psr-15", + "psr-7", + "psr15", + "psr7", + "route", + "router" + ], + "support": { + "issues": "https://github.com/thephpleague/route/issues", + "source": "https://github.com/thephpleague/route/tree/6.2.0" + }, + "funding": [ + { + "url": "https://github.com/philipobenito", + "type": "github" + } + ], + "time": "2024-11-25T08:10:15+00:00" + }, + { + "name": "league/tactician", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/tactician.git", + "reference": "e79f763170f3d5922ec29e85cffca0bac5cd8975" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/tactician/zipball/e79f763170f3d5922ec29e85cffca0bac5cd8975", + "reference": "e79f763170f3d5922ec29e85cffca0bac5cd8975", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^9.3.8", + "squizlabs/php_codesniffer": "^3.5.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Tactician\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ross Tuck", + "homepage": "http://tactician.thephpleague.com" + } + ], + "description": "A small, flexible command bus. Handy for building service layers.", + "keywords": [ + "command", + "command bus", + "service layer" + ], + "support": { + "issues": "https://github.com/thephpleague/tactician/issues", + "source": "https://github.com/thephpleague/tactician/tree/v1.1.0" + }, + "time": "2021-02-14T15:29:04+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.3", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-09-06T13:39:36+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/FastRoute.git", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "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.6.2" + }, + "time": "2025-10-21T19:32:17+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": "phpstan/phpdoc-parser", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", + "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + }, + "time": "2025-08-30T15:50:23+00:00" + }, + { + "name": "predis/predis", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8", + "reference": "9e9deec4dfd3ebf65d32eb368f498c646ba2ecd8", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krรผss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2025-08-06T06:41:24+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+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/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+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": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "react/async", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/async.git", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/async/zipball/635d50e30844a484495713e8cb8d9e079c0008a5", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.8 || ^1.2.1" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lรผck", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async utilities and fibers for ReactPHP", + "keywords": [ + "async", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/async/issues", + "source": "https://github.com/reactphp/async/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:40:02+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lรผck", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lรผck", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "roadrunner-php/app-logger", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/app-logger.git", + "reference": "555a31933c7797cfb5749a5c7176d39c2b368183" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/app-logger/zipball/555a31933c7797cfb5749a5c7176d39c2b368183", + "reference": "555a31933c7797cfb5749a5c7176d39c2b368183", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "roadrunner-php/roadrunner-api-dto": "^1.4", + "spiral/goridge": "^3.1 || ^4.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": ">=5.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "RoadRunner\\Logger\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kirill Astakhov (kastahov)", + "email": "kirill.astakhov@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/spiral/roadrunner/graphs/contributors" + } + ], + "description": "Send log messages to RoadRunner", + "support": { + "source": "https://github.com/roadrunner-php/app-logger/tree/1.2.0" + }, + "funding": [ + { + "url": "https://github.com/roadrunner-server", + "type": "github" + } + ], + "time": "2023-12-22T06:01:40+00:00" + }, + { + "name": "roadrunner-php/roadrunner-api-dto", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/roadrunner-api-dto.git", + "reference": "e6efb759f0a73b8516b7f28317230ecd4010005e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/roadrunner-api-dto/zipball/e6efb759f0a73b8516b7f28317230ecd4010005e", + "reference": "e6efb759f0a73b8516b7f28317230ecd4010005e", + "shasum": "" + }, + "require": { + "google/protobuf": "^4.31.1", + "php": "^8.1" + }, + "conflict": { + "temporal/sdk": "<2.9.0" + }, + "suggest": { + "google/common-protos": "Required for Temporal API" + }, + "type": "library", + "autoload": { + "psr-4": { + "Temporal\\": "generated/Temporal", + "RoadRunner\\": "generated/RoadRunner", + "GPBMetadata\\": "generated/GPBMetadata" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Pavel Butchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Aleksei Gagarin (roxblnfk)", + "email": "alexey.gagarin@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/roadrunner-server/roadrunner/graphs/contributors" + } + ], + "description": "RoadRunner PHP API", + "homepage": "https://roadrunner.dev", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev", + "forum": "https://forum.roadrunner.dev", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-php/roadrunner-api-dto/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-11-06T13:03:11+00:00" + }, + { + "name": "robinvdvleuten/ulid", + "version": "v5.0.0", + "source": { + "type": "git", + "url": "https://github.com/robinvdvleuten/php-ulid.git", + "reference": "5389c9a2ff020815cc1f2b840334fdcb84ae3f35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robinvdvleuten/php-ulid/zipball/5389c9a2ff020815cc1f2b840334fdcb84ae3f35", + "reference": "5389c9a2ff020815cc1f2b840334fdcb84ae3f35", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "require-dev": { + "phpbench/phpbench": "^1.0.0-alpha3", + "phpunit/phpunit": "^8.5", + "symfony/phpunit-bridge": "^5.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ulid\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin van der Vleuten", + "email": "robin@webstronauts.co" + } + ], + "description": "Universally Unique Lexicographically Sortable Identifier (ULID) implementation for PHP.", + "homepage": "https://github.com/robinvdvleuten/php-ulid", + "support": { + "issues": "https://github.com/robinvdvleuten/php-ulid/issues", + "source": "https://github.com/robinvdvleuten/php-ulid/tree/v5.0.0" + }, + "time": "2020-12-06T19:13:21+00:00" + }, + { + "name": "siteworxpro/config", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://gitea.siteworxpro.com/php-packages/config", + "reference": "1.1.1" + }, + "dist": { + "type": "zip", + "url": "https://gitea.siteworxpro.com/api/packages/php-packages/composer/files/siteworxpro%2Fconfig/1.1.1/siteworxpro-config.1.1.1.zip", + "shasum": "6cca770db21c1ce0a480953aac8c289c9875d5ea" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", + "phpunit/phpunit": "^9.5", + "scrutinizer/ocular": "~1.1", + "slevomat/coding-standard": "^7.0.18", + "squizlabs/php_codesniffer": "~3.6.1", + "symfony/yaml": "~3.4" + }, + "suggest": { + "symfony/yaml": "~3.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Siteworx\\Config\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Siteworx\\Config\\Test\\": "tests" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Hassan Khan", + "homepage": "http://hassankhan.me/" + }, + { + "name": "Ron Rise" + } + ], + "description": "Lightweight configuration file loader that supports PHP, INI, XML, JSON, and YAML files", + "homepage": "http://hassankhan.me/config/", + "keywords": [ + "config", + "configuration", + "ini", + "json", + "microphp", + "unframework", + "xml", + "yaml", + "yml" + ], + "time": "2025-08-15T19:08:49+00:00" + }, + { + "name": "siteworxpro/http-status", + "version": "0.0.2", + "source": { + "type": "git", + "url": "https://gitea.siteworxpro.com/php-packages/http-status", + "reference": "0.0.2" + }, + "dist": { + "type": "zip", + "url": "https://gitea.siteworxpro.com/api/packages/php-packages/composer/files/siteworxpro%2Fhttp-status/0.0.2/siteworxpro-http-status.0.0.2.zip", + "shasum": "2eee4cd2605aa4b64ce18d18eb651764e9e88dbf" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Siteworxpro\\HttpStatus\\": "src/" + } + }, + "license": [ + "MIT" + ], + "time": "2025-06-20T12:46:36+00:00" + }, + { + "name": "spiral/goridge", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/goridge.git", + "reference": "2a372118dac1f0c0511e2862f963ce649fefd9fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/goridge/zipball/2a372118dac1f0c0511e2862f963ce649fefd9fa", + "reference": "2a372118dac1f0c0511e2862f963ce649fefd9fa", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-sockets": "*", + "php": ">=8.1", + "spiral/roadrunner": "^2023 || ^2024.1 || ^2025.1" + }, + "require-dev": { + "google/protobuf": "^3.22 || ^4.0", + "infection/infection": "^0.29.0", + "jetbrains/phpstorm-attributes": "^1.0", + "phpunit/phpunit": "^10.5.45", + "rybakit/msgpack": "^0.7", + "spiral/code-style": "*", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "ext-msgpack": "MessagePack codec support", + "ext-protobuf": "Protobuf codec support", + "google/protobuf": "(^3.0) Protobuf codec support", + "rybakit/msgpack": "(^0.7) MessagePack codec support" + }, + "type": "goridge", + "autoload": { + "psr-4": { + "Spiral\\Goridge\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Titov (wolfy-j)", + "email": "wolfy-j@spiralscout.com" + }, + { + "name": "Valery Piashchynski", + "homepage": "https://github.com/rustatian" + }, + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + }, + { + "name": "Pavel Buchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Maksim Smakouz (msmakouz)", + "email": "maksim.smakouz@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/roadrunner-server/roadrunner/graphs/contributors" + } + ], + "description": "High-performance PHP-to-Golang RPC bridge", + "homepage": "https://spiral.dev/", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-php/goridge/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-05-05T13:55:33+00:00" + }, + { + "name": "spiral/roadrunner", + "version": "v2025.1.5", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-server/roadrunner.git", + "reference": "d68bee29eb689c5310f8ede935c95a13bd7cc153" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-server/roadrunner/zipball/d68bee29eb689c5310f8ede935c95a13bd7cc153", + "reference": "d68bee29eb689c5310f8ede935c95a13bd7cc153", + "shasum": "" + }, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Titov / Wolfy-J", + "email": "wolfy.jd@gmail.com" + }, + { + "name": "Valery Piashchynski", + "homepage": "https://github.com/rustatian" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/roadrunner-server/roadrunner/graphs/contributors" + } + ], + "description": "RoadRunner: High-performance PHP application server and process manager written in Go and powered with plugins", + "homepage": "https://roadrunner.dev/", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev/", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-server/roadrunner/tree/v2025.1.5" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-11-13T17:24:29+00:00" + }, + { + "name": "spiral/roadrunner-grpc", + "version": "v3.5.2", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/grpc.git", + "reference": "916c061de160d6b2f3efc82dcffac0360d84fab8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/grpc/zipball/916c061de160d6b2f3efc82dcffac0360d84fab8", + "reference": "916c061de160d6b2f3efc82dcffac0360d84fab8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "google/common-protos": "^3.1|^4.0", + "google/protobuf": "^3.7 || ^4.0", + "php": ">=8.1", + "spiral/goridge": "^4.0", + "spiral/roadrunner": "^2024.3 || ^2025.1", + "spiral/roadrunner-worker": "^3.0", + "symfony/polyfill-php83": "*" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.0", + "mockery/mockery": "^1.4", + "phpunit/phpunit": "^10.0", + "spiral/code-style": "^2.2", + "spiral/dumper": "^3.3", + "vimeo/psalm": ">=5.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spiral\\RoadRunner\\GRPC\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Titov (wolfy-j)", + "email": "wolfy-j@spiralscout.com" + }, + { + "name": "Pavel Buchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Aleksei Gagarin (roxblnfk)", + "email": "alexey.gagarin@spiralscout.com" + }, + { + "name": "Maksim Smakouz (msmakouz)", + "email": "maksim.smakouz@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/spiral/roadrunner/graphs/contributors" + } + ], + "description": "High-Performance GRPC server for PHP applications", + "homepage": "https://roadrunner.dev/", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev", + "forum": "https://forum.roadrunner.dev/", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-php/grpc/tree/v3.5.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-05-18T13:54:33+00:00" + }, + { + "name": "spiral/roadrunner-http", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/http.git", + "reference": "a44a5f7d54d4ee8a14fe99cd22dcd128db270c88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/http/zipball/a44a5f7d54d4ee8a14fe99cd22dcd128db270c88", + "reference": "a44a5f7d54d4ee8a14fe99cd22dcd128db270c88", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "psr/http-factory": "^1.0.1", + "psr/http-message": "^1.0.1 || ^2.0", + "roadrunner-php/roadrunner-api-dto": "^1.6", + "spiral/roadrunner": "^2023.3 || ^2024.1 || ^2025.1", + "spiral/roadrunner-worker": "^3.5", + "symfony/polyfill-php83": "^1.29" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.0", + "nyholm/psr7": "^1.3", + "phpunit/phpunit": "^10.5", + "spiral/code-style": "^2.3", + "spiral/dumper": "^3.3", + "symfony/process": "^6.2 || ^7.0", + "vimeo/psalm": "^6.13" + }, + "suggest": { + "ext-protobuf": "Provides Protocol Buffers support. Without it, performance will be lower.", + "spiral/roadrunner-cli": "Provides RoadRunner installation and management CLI tools" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spiral\\RoadRunner\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Titov (wolfy-j)", + "email": "wolfy-j@spiralscout.com" + }, + { + "name": "Valery Piashchynski", + "homepage": "https://github.com/rustatian" + }, + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + }, + { + "name": "Pavel Buchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Maksim Smakouz (msmakouz)", + "email": "maksim.smakouz@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/roadrunner-server/roadrunner/graphs/contributors" + } + ], + "description": "RoadRunner: HTTP and PSR-7 worker", + "homepage": "https://spiral.dev/", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev", + "forum": "https://forum.roadrunner.dev/", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-php/http/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-08-31T12:42:23+00:00" + }, + { + "name": "spiral/roadrunner-worker", + "version": "v3.6.2", + "source": { + "type": "git", + "url": "https://github.com/roadrunner-php/worker.git", + "reference": "8d9905b1e6677f34ff8623893f35b5e2fa828e37" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roadrunner-php/worker/zipball/8d9905b1e6677f34ff8623893f35b5e2fa828e37", + "reference": "8d9905b1e6677f34ff8623893f35b5e2fa828e37", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-json": "*", + "ext-sockets": "*", + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0", + "spiral/goridge": "^4.1.0", + "spiral/roadrunner": "^2023.1 || ^2024.1 || ^2025.1" + }, + "require-dev": { + "buggregator/trap": "^1.13", + "jetbrains/phpstorm-attributes": "^1.0", + "phpunit/phpunit": "^10.5.45", + "spiral/code-style": "^2.2", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "spiral/roadrunner-cli": "Provides RoadRunner installation and management CLI tools" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spiral\\RoadRunner\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Anton Titov (wolfy-j)", + "email": "wolfy-j@spiralscout.com" + }, + { + "name": "Valery Piashchynski", + "homepage": "https://github.com/rustatian" + }, + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + }, + { + "name": "Pavel Buchnev (butschster)", + "email": "pavel.buchnev@spiralscout.com" + }, + { + "name": "Maksim Smakouz (msmakouz)", + "email": "maksim.smakouz@spiralscout.com" + }, + { + "name": "RoadRunner Community", + "homepage": "https://github.com/roadrunner-server/roadrunner/graphs/contributors" + } + ], + "description": "RoadRunner: PHP worker", + "homepage": "https://spiral.dev/", + "support": { + "chat": "https://discord.gg/V6EK4he", + "docs": "https://docs.roadrunner.dev", + "issues": "https://github.com/roadrunner-server/roadrunner/issues", + "source": "https://github.com/roadrunner-php/worker/tree/v3.6.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/roadrunner-server", + "type": "github" + } + ], + "time": "2025-05-05T12:34:50+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "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": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-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.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "7598dd5770580fa3517ec83e8da0c9b9e01f4291" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/7598dd5770580fa3517ec83e8da0c9b9e01f4291", + "reference": "7598dd5770580fa3517ec83e8da0c9b9e01f4291", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.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": "2025-11-05T14:36:47+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "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.33.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": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "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.33.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": "2025-07-08T02:45:35+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "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\\Php84\\": "" + }, + "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.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.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": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "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\\Php85\\": "" + }, + "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.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.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": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174", + "reference": "ec25870502d0c7072d086e8ffba1420c85965174", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.3.4" + }, + "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": "2025-09-07T11:39:36+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "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 translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + }, + "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": "2025-07-15T13:41:35+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.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": "2025-11-16T10:14:42+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "5.7.5", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "9a37739401485b42d779495e70548309820d11d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/9a37739401485b42d779495e70548309820d11d6", + "reference": "9a37739401485b42d779495e70548309820d11d6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=7.4", + "phpstan/phpdoc-parser": "^2.0", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^1.6 || ^2.0", + "phpunit/phpunit": "^9.0", + "rector/rector": "^1.0 || ^2.0", + "vimeo/psalm": "^4.30 || ^5.0" + }, + "suggest": { + "doctrine/annotations": "^2.0", + "radebatz/type-info-extras": "^1.0.2" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/5.7.5" + }, + "time": "2025-11-28T23:22:21+00:00" + } + ], + "packages-dev": [ + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "kwn/php-rdkafka-stubs", + "version": "v2.2.1", + "source": { + "type": "git", + "url": "https://github.com/kwn/php-rdkafka-stubs.git", + "reference": "23b865d6b3e8fe1f080aa7371dc1da3339361996" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kwn/php-rdkafka-stubs/zipball/23b865d6b3e8fe1f080aa7371dc1da3339361996", + "reference": "23b865d6b3e8fe1f080aa7371dc1da3339361996", + "shasum": "" + }, + "require": { + "ext-rdkafka": ">=4.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.2.4" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Karol Wnuk", + "email": "k.wnuk@ascetic.pl" + } + ], + "description": "Rdkafka extension stubs for your IDE", + "support": { + "issues": "https://github.com/kwn/php-rdkafka-stubs/issues", + "source": "https://github.com/kwn/php-rdkafka-stubs/tree/v2.2.1" + }, + "time": "2022-08-16T15:27:51+00:00" + }, + { + "name": "lendable/composer-license-checker", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/Lendable/composer-license-checker.git", + "reference": "476039e57ceb3e6899a2d9d448ba5203cdfd6e97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Lendable/composer-license-checker/zipball/476039e57ceb3e6899a2d9d448ba5203cdfd6e97", + "reference": "476039e57ceb3e6899a2d9d448ba5203cdfd6e97", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/process": "^5.4.46 || ^6.4.14 || ^7.1.7" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.43.0", + "lendable/phpunit-extensions": "^0.3", + "php-cs-fixer/shim": "^3.61.1", + "phpstan/phpstan": "^1.11.9", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4.0", + "phpstan/phpstan-strict-rules": "^1.6.0", + "phpunit/phpunit": "^11.3.0", + "rector/rector": "^1.2.2", + "symfony/filesystem": "^6.2" + }, + "bin": [ + "bin/composer-license-checker" + ], + "type": "project", + "autoload": { + "psr-4": { + "Lendable\\ComposerLicenseChecker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lendable Ltd", + "email": "contact@lendable.co.uk" + } + ], + "description": "Composer license checker", + "support": { + "issues": "https://github.com/Lendable/composer-license-checker/issues", + "source": "https://github.com/Lendable/composer-license-checker/tree/1.2.2" + }, + "time": "2024-11-29T18:58:25+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pรกdraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+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": "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.1.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", + "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "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": "2025-11-11T15:18:17+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.4.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", + "reference": "67e8aed88f93d0e6e1cb7effe1a2dfc2fee6022c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.6.1", + "php": ">=8.3", + "phpunit/php-file-iterator": "^6.0", + "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": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.3.7" + }, + "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.4.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.4.0" + }, + "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": "2025-09-24T13:44:41+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", + "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "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.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:37+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.4.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "d8f644d8d9bb904867f7a0aeb1bd306e0d966949" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d8f644d8d9bb904867f7a0aeb1bd306e0d966949", + "reference": "d8f644d8d9bb904867f7a0aeb1bd306e0d966949", + "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.4.0", + "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.3", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.4-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.3" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "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/phpunit", + "type": "tidelift" + } + ], + "time": "2025-11-13T07:20:26+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "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.0" + }, + "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": "2025-09-14T09:36:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/dc904b4bb3ab070865fa4068cd84f3da8b945148", + "reference": "dc904b4bb3ab070865fa4068cd84f3da8b945148", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, + "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.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/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-20T11:27:00+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.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-posix": "*" + }, + "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": "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.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/environment", + "type": "tidelift" + } + ], + "time": "2025-08-12T14:11:56+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "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" + }, + { + "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.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/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:16:11+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.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for 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.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:28+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.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "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": "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.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/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:57:12+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": "squizlabs/php_codesniffer", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-10T16:43:36+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": "symfony/console", + "version": "v7.3.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "reference": "c28ad91448f86c5f6d9d2c70f0cf68bf135f252a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.6" + }, + "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": "2025-11-04T01:21:42+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.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": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b", + "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.4" + }, + "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": "2025-09-11T10:12:26+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "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.6-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.6.1" + }, + "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": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f96476035142921000338bad71e5247fbc138872" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872", + "reference": "f96476035142921000338bad71e5247fbc138872", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.4" + }, + "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": "2025-09-11T14:36:48+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "d74205c497bfbca49f34d4bc4c19c17e22db4ebb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/d74205c497bfbca49f34d4bc4c19c17e22db4ebb", + "reference": "d74205c497bfbca49f34d4bc4c19c17e22db4ebb", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-13T13:44:09+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.5" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..4b00213 --- /dev/null +++ b/config.php @@ -0,0 +1,88 @@ + [ + 'log_level' => Env::get('LOG_LEVEL', 'debug'), + 'dev_mode' => Env::get('DEV_MODE', false, 'bool'), + ], + + /** + * The server configuration. + */ + 'server' => [ + 'port' => Env::get('HTTP_PORT', 9501, 'int'), + ], + + /** + * The database configuration. + */ + 'db' => [ + 'driver' => Env::get('DB_DRIVER', 'pgsql'), + 'host' => Env::get('DB_HOST', 'localhost'), + 'database' => Env::get('DB_DATABASE', 'siteworxpro'), + 'username' => Env::get('DB_USERNAME', 'siteworxpro'), + 'password' => Env::get('DB_PASSWORD', 'password'), + 'port' => Env::get('DB_PORT', 5432, 'int'), + 'charset' => Env::get('DB_CHARSET', 'utf8'), + 'collation' => Env::get('DB_COLLATION', 'utf8_unicode_ci'), + 'prefix' => Env::get('DB_PREFIX', ''), + 'options' => [ + // Add any additional PDO options here + ], + ], + + 'cors' => [ + 'allowed_origins' => Env::get('CORS_ALLOWED_ORIGINS', 'localhost:3000'), + 'allow_credentials' => Env::get('CORS_ALLOW_CREDENTIALS', true, 'bool'), + 'max_age' => Env::get('CORS_MAX_AGE', ''), + ], + + 'redis' => [ + 'host' => Env::get('REDIS_HOST', 'localhost'), + 'port' => Env::get('REDIS_PORT', 6379, 'int'), + 'database' => Env::get('REDIS_DATABASE', 0, 'int'), + 'password' => Env::get('REDIS_PASSWORD'), + ], + + 'jwt' => [ + 'signing_key' => Env::get('JWT_SIGNING_KEY', 'a_super_secret_key'), + 'audience' => Env::get('JWT_AUDIENCE', 'my_audience'), + 'issuer' => Env::get('JWT_ISSUER', 'my_issuer'), + 'strict_validation' => Env::get('JWT_STRICT_VALIDATION', false, 'bool'), + ], + + 'queue' => [ + 'broker' => Env::get('QUEUE_BROKER', 'redis'), + + 'broker_config' => [ + + 'redis' => [ + 'consumerGroup' => Env::get('QUEUE_REDIS_CONSUMER_GROUP', ''), + ], + + 'kafka' => [ + 'brokers' => Env::get('QUEUE_KAFKA_BROKERS', 'kafka:9092'), + 'consumerGroup' => Env::get('QUEUE_KAFKA_CONSUMER_GROUP', 'default_group'), + ], + + 'rabbitmq' => [ + 'host' => Env::get('QUEUE_RABBITMQ_HOST', 'localhost'), + 'port' => Env::get('QUEUE_RABBITMQ_PORT', 5672, 'int'), + 'username' => Env::get('QUEUE_RABBITMQ_USERNAME', 'guest'), + 'password' => Env::get('QUEUE_RABBITMQ_PASSWORD', 'guest'), + 'vhost' => Env::get('QUEUE_RABBITMQ_VHOST', '/'), + ], + + 'sqs' => [ + 'key' => Env::get('QUEUE_SQS_KEY', ''), + 'secret' => Env::get('QUEUE_SQS_SECRET', ''), + 'region' => Env::get('QUEUE_SQS_REGION', 'us-east-1'), + 'version' => Env::get('QUEUE_SQS_VERSION', 'latest'), + 'queue_url' => Env::get('QUEUE_SQS_QUEUE_URL', ''), + ] + ] + ] +]; diff --git a/db/migrations/000001_create_users_table.down.sql b/db/migrations/000001_create_users_table.down.sql new file mode 100644 index 0000000..5f18ac0 --- /dev/null +++ b/db/migrations/000001_create_users_table.down.sql @@ -0,0 +1 @@ +drop table if exists users; \ No newline at end of file diff --git a/db/migrations/000001_create_users_table.up.sql b/db/migrations/000001_create_users_table.up.sql new file mode 100644 index 0000000..9488b93 --- /dev/null +++ b/db/migrations/000001_create_users_table.up.sql @@ -0,0 +1,13 @@ +create table users +( + id uuid default gen_random_uuid() + constraint users_pk + primary key, + first_name varchar not null, + last_name varchar not null, + email varchar not null + constraint users_email_key + unique, + password varchar not null, + created_at timestamp default now() +); diff --git a/generated/GRPC/GPBMetadata/Example.php b/generated/GRPC/GPBMetadata/Example.php new file mode 100644 index 0000000..09e16d5 --- /dev/null +++ b/generated/GRPC/GPBMetadata/Example.php @@ -0,0 +1,25 @@ +internalAddGeneratedFile( + "\x0A\xE5\x01\x0A\x14protos/example.proto\x12\x0Ahelloworld\"\x1C\x0A\x0CHelloRequest\x12\x0C\x0A\x04name\x18\x01 \x01(\x09\"\x1D\x0A\x0AHelloReply\x12\x0F\x0A\x07message\x18\x01 \x01(\x092I\x0A\x07Greeter\x12>\x0A\x08SayHello\x12\x18.helloworld.HelloRequest\x1A\x16.helloworld.HelloReply\"\x00B1Z\x0Dproto/greeter\xCA\x02\x0CGRPC\\Greeter\xE2\x02\x10GRPC\\GPBMetadatab\x06proto3" + , true); + + static::$is_initialized = true; + } +} + diff --git a/generated/GRPC/Greeter/GreeterInterface.php b/generated/GRPC/Greeter/GreeterInterface.php new file mode 100644 index 0000000..ad49827 --- /dev/null +++ b/generated/GRPC/Greeter/GreeterInterface.php @@ -0,0 +1,22 @@ +helloworld.HelloReply + */ +class HelloReply extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string message = 1; + */ + protected $message = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $message + * } + */ + public function __construct($data = NULL) { + \GRPC\GPBMetadata\Example::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string message = 1; + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * Generated from protobuf field string message = 1; + * @param string $var + * @return $this + */ + public function setMessage($var) + { + GPBUtil::checkString($var, True); + $this->message = $var; + + return $this; + } + +} + diff --git a/generated/GRPC/Greeter/HelloRequest.php b/generated/GRPC/Greeter/HelloRequest.php new file mode 100644 index 0000000..87bd96f --- /dev/null +++ b/generated/GRPC/Greeter/HelloRequest.php @@ -0,0 +1,61 @@ +helloworld.HelloRequest + */ +class HelloRequest extends \Google\Protobuf\Internal\Message +{ + /** + * Generated from protobuf field string name = 1; + */ + protected $name = ''; + + /** + * Constructor. + * + * @param array $data { + * Optional. Data for populating the Message object. + * + * @type string $name + * } + */ + public function __construct($data = NULL) { + \GRPC\GPBMetadata\Example::initOnce(); + parent::__construct($data); + } + + /** + * Generated from protobuf field string name = 1; + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Generated from protobuf field string name = 1; + * @param string $var + * @return $this + */ + public function setName($var) + { + GPBUtil::checkString($var, True); + $this->name = $var; + + return $this; + } + +} + diff --git a/generated/README.md b/generated/README.md new file mode 100644 index 0000000..aa2bde0 --- /dev/null +++ b/generated/README.md @@ -0,0 +1,3 @@ +### Note to Developers +Only generated files are allowed in this directory. +Please do not add any other files here manually. \ No newline at end of file diff --git a/grpc-worker.php b/grpc-worker.php new file mode 100644 index 0000000..0dbc05c --- /dev/null +++ b/grpc-worker.php @@ -0,0 +1,14 @@ +start()); +} catch (\Exception $e) { + echo $e->getMessage(); + + exit(1); +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..206a8e2 --- /dev/null +++ b/makefile @@ -0,0 +1,187 @@ +# Makefile (enhanced) +SHELL := /bin/sh +.DEFAULT_GOAL := help + +# Docker Compose file +COMPOSE_FILE := -f .dev/docker-compose.yml + +# Reusable vars +DOCKER := docker compose $(COMPOSE_FILE) +COMPOSER_RUNTIME := composer-runtime +DEV_RUNTIME := dev-runtime +MIGRATION_CONTAINER := migration-container + +PROTOC_GEN_DIR := ./protoc-gen-php-grpc-2025.1.5-darwin-arm64 +PROTOC_GEN := $(PROTOC_GEN_DIR)/protoc-gen-php-grpc +PROTOC_URL := https://github.com/roadrunner-server/roadrunner/releases/download/v2025.1.5/protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz + +DEV := $(DOCKER) exec $(DEV_RUNTIME) sh -c +COMPOSER := $(DOCKER) exec $(COMPOSER_RUNTIME) sh -c + +# Colors +GREEN := \033[32m +YELLOW := \033[33m +RESET := \033[0m + +# Fancy emoji +SPARK := โœจ +ROCKET := ๐Ÿš€ +WARN := โš ๏ธ +MAGNIFY := ๐Ÿ” +BUG := ๐Ÿž +COMPOSE := ๐Ÿณ +TRASH := ๐Ÿงน +PROTO := ๐Ÿงฉ +CHECK := โœ… +CROSS := โŒ + +# Align width for help display +HELP_COL_WIDTH := 26 + +# Help: auto-generate from targets with "##" comments +help: ## Show this help + @echo "$(SPARK) Available commands:" + @awk -F':|##' '/^[a-zA-Z0-9._-]+:.*##/ {printf " %-$(HELP_COL_WIDTH)s - %s\n", $$1, $$3}' $(MAKEFILE_LIST) | sort + +docker-build: ## Build Docker image + @push_flag=""; \ + if [ -n "$(push)" ]; then \ + push="--push"; \ + fi + @if [ -z "$(image)" ]; then \ + echo "image variable is required: make docker-build image=your-image-name tag=your"; \ + exit 1; \ + fi + @if [ -z "$(tag)" ]; then \ + echo "tag variable is required: make docker-build image=your-image-name tag=your"; \ + exit 1; \ + fi + @platform_flag=""; \ + if [ -n "$(platform)" ]; then \ + platform_flag="--platform=$(platform)"; \ + fi; \ + printf "$(YELLOW)$(SPARK) Building Docker image: $(image):$(tag)$(RESET)\n"; \ + docker buildx build $$platform_flag --provenance=true --sbom=true $$push_flag --tag $(image):$(tag) . + +start: ## Start the development runtime container + @printf "$(GREEN)$(ROCKET) Starting $(DEV_RUNTIME)$(RESET)\n" + $(DOCKER) up $(DEV_RUNTIME) -d --no-recreate + +sh: ## Open a shell in the development runtime container + @$(MAKE) start + $(DOCKER) exec $(DEV_RUNTIME) sh + +run: ## Run the application server in the development runtime container + @$(MAKE) start + $(DEV) "rr serve" + +stop: ## Stop and remove the development runtime container + @printf "$(YELLOW)$(WARN) Stopping all containers$(RESET)\n" + $(DOCKER) down + +restart: ## Restart dev container (stop + start) + @$(MAKE) stop + @$(MAKE) start + +rebuild: ## Rebuild containers (useful after Dockerfile changes) + @printf "$(YELLOW)$(SPARK) Rebuilding containers$(RESET)\n" + @$(MAKE) stop + @printf "$(YELLOW)$(TRASH) Deleting all Docker resources$(RESET)\n" + docker system prune --all --volumes --force + @$(MAKE) start + +ps: ## Show docker compose ps + $(DOCKER) ps + +migrate: ## Run database migrations in the migration container + $(DOCKER) up $(MIGRATION_CONTAINER) + +# Composer helpers +composer-install: ## Install PHP dependencies in the composer runtime container + @printf "$(COMPOSE) $(GREEN)Installing PHP dependencies in $(COMPOSER_RUNTIME)$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer install --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs" + +composer-install-no-dev: ## Install PHP dependencies without dev packages in the composer runtime container + @printf "$(COMPOSE) $(GREEN)Installing PHP dependencies (no-dev) in $(COMPOSER_RUNTIME)$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs" + +composer-require: ## Require a PHP package in the composer runtime container (usage: make composer-require package=vendor/package) +ifndef package + $(error package variable is required: make composer-require package=vendor/package) +endif + @printf "$(COMPOSE) $(MAGNIFY) Requiring package $(package) in $(COMPOSER_RUNTIME)$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer require $(package) --ignore-platform-reqs" + +composer-require-dev: ## Require a PHP package as dev in the composer runtime container (usage: make composer-require-dev package=vendor/package) +ifndef package + $(error package variable is required: make composer-require-dev package=vendor/package) +endif + @printf "$(COMPOSE) $(MAGNIFY) Requiring dev package $(package) in $(COMPOSER_RUNTIME)$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer require --dev $(package) --ignore-platform-reqs" + +composer-update: ## Update PHP dependencies in the composer runtime container + @printf "$(COMPOSE) $(MAGNIFY) Updating PHP dependencies$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer update --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-reqs" + +enable-debug: ## Enable Xdebug in the development runtime container + @$(DOCKER) up $(DEV_RUNTIME) -d --no-recreate + @printf "$(GREEN)$(BUG) Enabling Xdebug in $(DEV_RUNTIME)$(RESET)\n" + $(DEV) "bin/xdebug.sh" + +enable-coverage: ## Enable PCOV code coverage in the composer runtime container + @printf "$(GREEN)$(MAGNIFY) Enabling PCOV in $(COMPOSER_RUNTIME)$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "bin/pcov.sh" + +protoc: ## Generate PHP gRPC code from .proto files + @printf "$(PROTO) $(GREEN)Setting up protoc-gen-php-grpc plugin$(RESET)\n" + @curl -LOs $(PROTOC_URL) + @tar -xzf protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz + @printf "$(PROTO) $(GREEN)Generating PHP gRPC code from .proto files$(RESET)\n" + @protoc --plugin=./protoc-gen-php-grpc-2025.1.5-darwin-arm64/protoc-gen-php-grpc \ + --php_out=./generated \ + --php-grpc_out=./generated \ + protos/example.proto + @printf "$(TRASH) $(GREEN)Cleaning up protoc-gen-php-grpc plugin files$(RESET)\n" + @rm -rf $(PROTOC_GEN_DIR) protoc-gen-php-grpc-2025.1.5-darwin-arm64.tar.gz + +license-check: ## Check license headers in source files + @printf "$(MAGNIFY) $(GREEN)Checking license headers$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer run-script tests:license || true" + +# Developer tasks +lint: ## Run linting (phpcs/phpstan) in composer runtime + @printf "$(MAGNIFY) $(GREEN)Running linters$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer run-script tests:lint || true" + $(COMPOSER) "composer run-script tests:phpstan || true" + +fmt: ## Format code (php-cs-fixer) + @printf "$(MAGNIFY) $(GREEN)Formatting code$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d --no-recreate + $(COMPOSER) "composer run-script tests:lint:fix" + +test: ## Run test suite (phpunit) + @printf "$(CHECK) $(GREEN)Running unit tests$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d + $(COMPOSER) "composer run-script tests:unit || true" + +test-coverage: ## Run test suite with coverage report + @printf "$(CHECK) $(GREEN)Running unit tests with coverage report$(RESET)\n" + @$(DOCKER) up $(COMPOSER_RUNTIME) -d + @$(MAKE) enable-coverage + $(COMPOSER) "composer run-script tests:unit:coverage || true" + +# Convenience aliases +dev: run ## Alias for start +ci: composer-install migrate license-check lint test ## CI-like local flow +down: stop ## Alias for stop +up: start ## Alias for start + +.PHONY: help start sh run stop docker-build restart rebuild ps migrate composer-install composer-require composer-require-dev composer-update enable-debug enable-coverage protoc license-check lint fmt test test-coverage dev ci down up \ No newline at end of file diff --git a/migrations.Dockerfile b/migrations.Dockerfile new file mode 100644 index 0000000..ae4c6fe --- /dev/null +++ b/migrations.Dockerfile @@ -0,0 +1,8 @@ +FROM siteworxpro/migrate:v4.18.3 + +ADD db/migrations /app/db/migrations +ADD bin/migrate.sh /app/bin/migrate.sh + +WORKDIR /app + +ENTRYPOINT ["/app/bin/migrate.sh"] diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..00f248b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,3 @@ +parameters: + ignoreErrors: + - '#Static call to instance method#' \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..c7e4668 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + diff --git a/protos/example.proto b/protos/example.proto new file mode 100644 index 0000000..78ab89a --- /dev/null +++ b/protos/example.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +option go_package = "proto/greeter"; +option php_namespace = "GRPC\\Greeter"; +option php_metadata_namespace = "GRPC\\GPBMetadata"; + +package helloworld; + +// The greeting service definition. +service Greeter { + // Sends a greeting + rpc SayHello (HelloRequest) returns (HelloReply) {} +} + +// The request message containing the user's name. +message HelloRequest { + string name = 1; +} + +// The response message containing the greetings +message HelloReply { + string message = 1; +} \ No newline at end of file diff --git a/server.php b/server.php new file mode 100644 index 0000000..74aabcf --- /dev/null +++ b/server.php @@ -0,0 +1,14 @@ +startServer(); +} catch (JsonException $e) { + echo $e->getMessage(); + + exit(1); +} diff --git a/src/Api.php b/src/Api.php new file mode 100644 index 0000000..173bb2f --- /dev/null +++ b/src/Api.php @@ -0,0 +1,134 @@ +registerRoutes(); + } + + /** + * Registers the routes for the server. + * + * This method is responsible for defining the routes that the server will handle. + * It should be implemented in subclasses to provide specific route definitions. + * + * @return void + */ + public function registerRoutes(): void + { + $this->worker = new PSR7Worker( + Worker::create(), + new Psr17Factory(), + new Psr17Factory(), + new Psr17Factory() + ); + + $this->router = new Router(); + $this->router->get('/', IndexController::class . '::get'); + $this->router->post('/', IndexController::class . '::post'); + $this->router->get('/healthz', HealthcheckController::class . '::get'); + + $this->router->group('/.well-known', function (RouteGroup $router) { + $router->get('/swagger.yaml', OpenApiController::class . '::get'); + $router->get('/swagger.json', OpenApiController::class . '::get'); + }); + + $this->router->middleware(new CorsMiddleware()); + $this->router->middleware(new JwtMiddleware()); + $this->router->middleware(new ScopeMiddleware()); + } + + /** + * Starts the server and handles incoming requests. + * + * This method enters an infinite loop to continuously handle incoming HTTP requests. + * It decodes the request body, routes the request, and sends the response. It also handles + * exceptions and ensures proper cleanup after each request. + * + * @throws \JsonException If there is an error decoding the JSON request body. + */ + public function startServer(): void + { + Logger::info(sprintf('Server started: %s', microtime(true))); + Logger::info(sprintf('Server PID: %s', getmypid())); + Logger::info(sprintf('Server Listening on: 0.0.0.0:%s', Config::get('server.port'))); + + while (true) { + try { + $request = $this->worker->waitRequest(); + + if ($request === null) { + break; + } + + $request = $request->withParsedBody(json_decode($request->getBody()->getContents(), true)); + + $response = $this->router->handle($request); + $this->worker->respond($response); + } catch (MethodNotAllowedException | NotFoundException) { + $uri = ''; + if (isset($request)) { + $uri = $request->getUri()->getPath(); + } + + $this->worker->respond( + JsonResponseFactory::createJsonResponse(new NotFoundResponse($uri)) + ); + } catch (\Throwable $e) { + Logger::error($e->getMessage()); + Logger::error($e->getTraceAsString()); + + $this->worker->respond( + JsonResponseFactory::createJsonResponse(new ServerErrorResponse($e)) + ); + } + } + } +} diff --git a/src/Async/Brokers/Broker.php b/src/Async/Brokers/Broker.php new file mode 100644 index 0000000..ff8586e --- /dev/null +++ b/src/Async/Brokers/Broker.php @@ -0,0 +1,19 @@ + Redis::class, + 'rabbitmq' => RabbitMQ::class, + 'kafka' => Kafka::class, + 'sqs' => Sqs::class, + ]; + + public function __construct(protected $config = []) + { + } +} diff --git a/src/Async/Brokers/BrokerInterface.php b/src/Async/Brokers/BrokerInterface.php new file mode 100644 index 0000000..6e27300 --- /dev/null +++ b/src/Async/Brokers/BrokerInterface.php @@ -0,0 +1,21 @@ +set('bootstrap.servers', $config['brokers'] ?? 'localhost:9092'); + + $this->producer = new Producer($conf); + $this->producer->addBrokers($config['brokers'] ?? 'localhost:9092'); + + $conf->set('group.id', $config['consumerGroup'] ?? 'default'); + $conf->set('auto.offset.reset', 'earliest'); + $this->consumer = new KafkaConsumer($conf); + } + + public function __destruct() + { + $this->producer->flush(1000); + } + + /** + * @throws \Exception + */ + public function publish(Queue $queue, Message $message, ?int $delay = null): void + { + $topic = $this->producer->newTopic($queue->queueName()); + $topic->produce(RD_KAFKA_PARTITION_UA, 0, $message->serialize(), $message->getId()); + $this->producer->flush(1000); + } + + /** + * @throws Exception + */ + public function consume(Queue $queue): Message|null + { + $this->consumer->subscribe([$queue->queueName()]); + $kafkaMessage = $this->consumer->consume(1000); + + if ($kafkaMessage->err === RD_KAFKA_RESP_ERR__TIMED_OUT) { + return null; + } + + if ($kafkaMessage->err === RD_KAFKA_RESP_ERR_UNKNOWN_TOPIC_OR_PART) { + throw new \RuntimeException( + "Topic '{$queue->queueName()}' or partition does not exist. Kafka does not auto-create topics" . + " unless configured to do so." + ); + } + + /** @var string | null $messageData */ + $messageData = $kafkaMessage->payload; + if ($messageData !== null) { + /** @var Message $message */ + $message = unserialize($messageData, ['allowed_classes' => true]); + $message->setId((string)$kafkaMessage->offset); + + return $message; + } + + return null; + } + + public function acknowledge(Queue $queue, Message $message): void + { + } + + public function reject(Queue $queue, Message $message, bool $requeue = false): void + { + } + + public function purge(Queue $queue): void + { + } +} diff --git a/src/Async/Brokers/RabbitMQ.php b/src/Async/Brokers/RabbitMQ.php new file mode 100644 index 0000000..16a8ce3 --- /dev/null +++ b/src/Async/Brokers/RabbitMQ.php @@ -0,0 +1,36 @@ +client = \Siteworxpro\App\Services\Facades\Redis::getFacadeRoot(); + $this->consumerId = php_uname('n') . ':' . getmypid(); + $this->consumerGroup = $config['consumerGroup'] ?? 'default'; + } + + private function ensureQueue(string $queueName): void + { + if (in_array($queueName, $this->queueNames, true)) { + return; + } + + try { + $this->client->executeCommand( + new RawCommand( + 'XGROUP', + [ + 'CREATE', + self::QUEUE_PREFIX . $queueName, + self::CONSUMER_ID_PREFIX . $this->consumerGroup, + '$', + 'MKSTREAM' + ] + ) + ); + } catch (\Exception) { + // If the group already exists, we catch the exception and ignore it + // This is because Redis will throw an error if the group already exists + // We can safely ignore this error as it means the group is already set up + } + + $this->client->executeCommand( + new RawCommand( + 'XGROUP', + [ + 'CREATECONSUMER', + self::QUEUE_PREFIX . $queueName, + self::CONSUMER_ID_PREFIX . $this->consumerGroup, + $this->consumerId + ] + ) + ); + + $this->queueNames[] = $queueName; + } + + public function __destruct() + { + foreach ($this->queueNames as $queueName) { + try { + $this->client->executeCommand( + new RawCommand( + 'XGROUP', + [ + 'DELCONSUMER', + self::QUEUE_PREFIX . $queueName, + self::CONSUMER_ID_PREFIX . $this->consumerGroup, + $this->consumerId + ] + ) + ); + } catch (\Exception) { + // Ignore exceptions during cleanup + } + } + } + + /** + * @throws \Exception + */ + public function publish(Queue $queue, Message $message, ?int $delay = null): void + { + $command = '%s * data %s'; + $command = sprintf( + $command, + self::QUEUE_PREFIX . + $queue->queueName(), + base64_encode($message->serialize()) + ); + + /** @var string $result */ + $result = $this + ->client + ->executeCommand( + new RawCommand('XADD', explode(' ', $command)), + ); + + $message->setId($result); + } + + public function consume(Queue $queue): Message|null + { + $this->ensureQueue($queue->queueName()); + + $command = 'GROUP %s %s COUNT 1 STREAMS %s >'; + $command = sprintf( + $command, + self::CONSUMER_ID_PREFIX . $this->consumerGroup, + $this->consumerId, + self::QUEUE_PREFIX . $queue->queueName(), + ); + + /** @var array | null $response */ + $response = $this + ->client + ->executeCommand( + new RawCommand( + 'XREADGROUP', + explode(' ', $command) + ) + ); + + if ($response === null || !isset($response[0][1][0][1][1])) { + return null; + } + + $messageData = base64_decode($response[0][1][0][1][1]); + $messageId = $response[0][1][0][0]; + + if ($messageData === 'NOOP') { + // If the message is a NOOP, we return null to indicate no actual message + return null; + } + + $value = unserialize($messageData, ['allowed_classes' => true]); + if (!$value instanceof Message) { + return null; + } + + $value->setId($messageId); + + return $value; + } + + public function acknowledge(Queue $queue, Message $message): void + { + $response = $this + ->client + ->executeCommand( + new RawCommand( + 'XACK', + [ + self::QUEUE_PREFIX . $queue->queueName(), + self::CONSUMER_ID_PREFIX . $this->consumerGroup, + $message->getId() + ] + ) + ); + } + + public function reject(Queue $queue, Message $message, bool $requeue = false): void + { + // TODO: Implement reject() method. + } + + public function purge(Queue $queue): void + { + + // TODO: Implement purge() method. + } +} diff --git a/src/Async/Brokers/Sqs.php b/src/Async/Brokers/Sqs.php new file mode 100644 index 0000000..2e90634 --- /dev/null +++ b/src/Async/Brokers/Sqs.php @@ -0,0 +1,36 @@ + */ + private const array QUEUES = [ + 'default' => Queues\DefaultQueue::class, + ]; + + /** @var Queue[] */ + private array $queues = []; + + /** @var array message FQCN => handler FQCNs */ + private array $handlers = []; + + private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\Async\\Handlers\\'; + + /** + * @param string[] $queues Optional list of queue names (keys from self::QUEUES) + */ + public function __construct(array $queues = []) + { + $queueClasses = $queues === [] + ? array_values(self::QUEUES) + : array_map( + static function (string $name): string { + if (!isset(self::QUEUES[$name])) { + throw new \InvalidArgumentException("Queue '$name' is not defined."); + } + return self::QUEUES[$name]; + }, + $queues + ); + + foreach ($queueClasses as $class) { + $this->queues[] = new $class(); + } + + $this->registerHandlers(); + } + + /** + * Discover handler classes under `Handlers` and register them via HandlesMessage attributes. + */ + private function registerHandlers(): void + { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(__DIR__ . '/Handlers/') + ); + + /** @var \SplFileInfo $file */ + foreach ($it as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $relative = str_replace(__DIR__ . '/Handlers/', '', $file->getPathname()); + $class = self::HANDLER_NAMESPACE . str_replace('/', '\\', substr($relative, 0, -4)); + + if (!class_exists($class)) { + continue; + } + + $ref = new \ReflectionClass($class); + foreach ($ref->getAttributes(HandlesMessage::class) as $attr) { + $messageClass = $attr->newInstance()->getMessageClass(); + $this->handlers[$messageClass][] = $class; + } + } + } + + /** + * Signal handler used to initiate graceful or immediate shutdown. + */ + public static function handleSignal(int $signal): void + { + switch ($signal) { + case SIGINT: + case SIGTERM: + case SIGHUP: + self::$shutDown = true; + return; + case SIGKILL: + exit(9); + } + } + + private function shouldShutDown(): bool + { + return self::$shutDown; + } + + /** + * Start the consumer main loop. + */ + public function start(): void + { + if (!\function_exists('pcntl_signal')) { + throw new \RuntimeException('The pcntl extension is required to handle signals.'); + } + + Logger::info('Starting queue consumer...'); + Logger::info('Using Broker: ' . Broker::getFacadeRoot()::class); + + foreach ([SIGINT, SIGTERM, SIGHUP] as $sig) { + \pcntl_signal($sig, [self::class, 'handleSignal']); + } + + while (true) { + if ($this->shouldShutDown()) { + Logger::info('Shutting down queue consumer...'); + break; + } + + /** @var Queue $queue */ + foreach ($this->queues as $queue) { + Logger::info('Listening to queue: ' . $queue->queueName()); + $message = $queue->pop(); + if (!$message) { + continue; + } + + Logger::info('Processing message of type: ' . get_class($message)); + + foreach ($this->getHandlersForMessage($message) as $handler) { + $handler($message); + } + + // Continue polling from the top of the loop after processing a message. + continue 2; + } + + // Avoid busy-looping when no messages are available. + sleep(1); + } + } + + /** + * @return callable[] Handler instances invokable with the message + */ + private function getHandlersForMessage(Message $message): array + { + $messageClass = get_class($message); + + if (!isset($this->handlers[$messageClass])) { + throw new \RuntimeException("No handler found for message class: $messageClass"); + } + + $callables = []; + foreach ($this->handlers[$messageClass] as $handlerClass) { + if (class_exists($handlerClass)) { + $callables[] = new $handlerClass(); + } + } + + return $callables; + } +} diff --git a/src/Async/Handlers/HandlerInterface.php b/src/Async/Handlers/HandlerInterface.php new file mode 100644 index 0000000..a649af4 --- /dev/null +++ b/src/Async/Handlers/HandlerInterface.php @@ -0,0 +1,12 @@ +getPayload()['name'] ?? 'Guest'; + + Logger::info(sprintf("Hello, %s!", $name)); + } +} diff --git a/src/Async/Messages/Message.php b/src/Async/Messages/Message.php new file mode 100644 index 0000000..c099a5c --- /dev/null +++ b/src/Async/Messages/Message.php @@ -0,0 +1,102 @@ +uniqueId = Ulid::generate(); + $this->timestamp = time(); + } + + protected function getQueue(): Queue + { + if ($this->queue === '') { + $this->queue = static::DEFAULT_QUEUE; + } + + return new $this->queue(); + } + + public function getId(): string + { + return $this->id; + } + + /** + * @param string $id + */ + public function setId(string $id): void + { + $this->id = $id; + } + + public function getPayload(): array + { + return $this->payload; + } + + public function getTimestamp(): int + { + return $this->timestamp; + } + + public function __serialize(): array + { + return [ + 'id' => $this->id, + 'payload' => $this->payload, + 'timestamp' => $this->timestamp, + 'queue' => $this->queue, + ]; + } + + public function __unserialize(array $data): void + { + $this->id = $data['id']; + $this->payload = $data['payload']; + $this->timestamp = $data['timestamp']; + $this->queue = $data['queue']; + } + + public function serialize(): string + { + return serialize($this); + } + + public function unserialize(string $data): Message + { + $unserializedData = unserialize($data, ['allowed_classes' => [Message::class]]); + + $this->id = $unserializedData['id']; + $this->uniqueId = $unserializedData['uniqueId']; + $this->payload = $unserializedData['payload']; + $this->timestamp = $unserializedData['timestamp']; + $this->queue = $unserializedData['queue']; + + return $this; + } +} diff --git a/src/Async/Messages/SayHelloMessage.php b/src/Async/Messages/SayHelloMessage.php new file mode 100644 index 0000000..3bed371 --- /dev/null +++ b/src/Async/Messages/SayHelloMessage.php @@ -0,0 +1,41 @@ +getQueue(), + $message + ); + } + + public static function dispatchLater(int $delay, ...$args): void + { + $name = $args[0] ?? 'World'; + $message = new self($name); + Broker::publishLater( + $message->getQueue(), + $message, + $delay + ); + } + + private function __construct( + private readonly string $name + ) { + parent::__construct(); + + $this->payload = [ + 'name' => $this->name, + ]; + } +} diff --git a/src/Async/Queues/DefaultQueue.php b/src/Async/Queues/DefaultQueue.php new file mode 100644 index 0000000..8c0b4db --- /dev/null +++ b/src/Async/Queues/DefaultQueue.php @@ -0,0 +1,13 @@ +messageClass; + } +} diff --git a/src/Attributes/CommandBus/HandlesCommand.php b/src/Attributes/CommandBus/HandlesCommand.php new file mode 100644 index 0000000..573a625 --- /dev/null +++ b/src/Attributes/CommandBus/HandlesCommand.php @@ -0,0 +1,22 @@ +audience === '') { + return Config::get('jwt.audience') ?? ''; + } + + return $this->audience; + } + + /** + * Get the expected issuer for validation. + * + * Returns the constructor-provided issuer when non-empty; otherwise falls back to `jwt.issuer` config. + * + * @return string The issuer value to enforce. + */ + public function getIssuer(): string + { + if ($this->issuer === '') { + return Config::get('jwt.issuer') ?? ''; + } + + return $this->issuer; + } +} diff --git a/src/Attributes/Guards/RequireAllScopes.php b/src/Attributes/Guards/RequireAllScopes.php new file mode 100644 index 0000000..95bd0c8 --- /dev/null +++ b/src/Attributes/Guards/RequireAllScopes.php @@ -0,0 +1,12 @@ + $scopes the required scopes + * @param string $claim the claim to check for scopes + * @param string $separator the separator used to split scopes in the claim + */ + public function __construct( + private array $scopes = [], + private string $claim = 'scope', + private string $separator = ' ' + ) { + } + + public function getScopes(): array + { + return $this->scopes; + } + + public function getClaim(): string + { + return $this->claim; + } + + public function getSeparator(): string + { + return $this->separator; + } +} diff --git a/src/Cli/App.php b/src/Cli/App.php new file mode 100644 index 0000000..6e4601a --- /dev/null +++ b/src/Cli/App.php @@ -0,0 +1,46 @@ +app = new Application('Php-Template', Version::VERSION); + + $this->app->add(new DemoCommand()); + $this->app->add(new Start()); + $this->app->add(new TestJob()); + } + + public function run(): int + { + $this->app->logo( + <<app->handle($_SERVER['argv']); + } +} diff --git a/src/Cli/Commands/CommandInterface.php b/src/Cli/Commands/CommandInterface.php new file mode 100644 index 0000000..990c3c2 --- /dev/null +++ b/src/Cli/Commands/CommandInterface.php @@ -0,0 +1,15 @@ +argument('[name]', 'Your name') + ->option('-g, --greet', 'Include a greeting message'); + } + + public function execute(): int + { + $pb = $this->progress(100); + + for ($i = 0; $i < 100; $i += 10) { + usleep(100000); // Simulate work + $pb->advance(10); + } + + $pb->finish(); + + $this->writer()->boldBlue("Demo Command Executed!\n"); + $name = $this->values()['name']; + $greet = $this->values()['greet'] ?? false; + + if ($greet) { + $this->writer()->green("Hello, $name! Welcome to the CLI demo.\n"); + } else { + $exampleCommand = new ExampleCommand($name); + $this->writer()->yellow(CommandBus::handle($exampleCommand)); + } + + return 0; + } +} diff --git a/src/Cli/Commands/Queue/Start.php b/src/Cli/Commands/Queue/Start.php new file mode 100644 index 0000000..76440c8 --- /dev/null +++ b/src/Cli/Commands/Queue/Start.php @@ -0,0 +1,32 @@ +argument('[queues]', 'The name of the queue to consume from. ex. "first_queue,second_queue"'); + } + + public function execute(): int + { + $queues = []; + if ($this->values()['queues'] !== null) { + $queues = explode(',', $this->values()['queues']); + } + + $consumer = new Consumer($queues); + $consumer->start(); + + return 0; + } +} diff --git a/src/Cli/Commands/Queue/TestJob.php b/src/Cli/Commands/Queue/TestJob.php new file mode 100644 index 0000000..7fb0764 --- /dev/null +++ b/src/Cli/Commands/Queue/TestJob.php @@ -0,0 +1,34 @@ +getAttributes(HandlesCommand::class); + + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $commandClass = $instance->commandClass; + $this->handlers[$commandClass] = $fullClassName; + } + } + } + } + } + + public function getHandlerForCommand($commandName) + { + if (isset($this->handlers[$commandName])) { + $handlerClass = $this->handlers[$commandName]; + return new $handlerClass(); + } + + throw new CanNotInvokeHandlerException("No handler found for command: " . $commandName); + } +} diff --git a/src/CommandBus/Commands/Command.php b/src/CommandBus/Commands/Command.php new file mode 100644 index 0000000..7fc4ee3 --- /dev/null +++ b/src/CommandBus/Commands/Command.php @@ -0,0 +1,9 @@ +name; + } +} \ No newline at end of file diff --git a/src/CommandBus/Handlers/CommandHandler.php b/src/CommandBus/Handlers/CommandHandler.php new file mode 100644 index 0000000..77ce9cb --- /dev/null +++ b/src/CommandBus/Handlers/CommandHandler.php @@ -0,0 +1,9 @@ +getName(); + Logger::info('Handling ExampleCommand for name: ' . $name); + + return 'Hello, ' . $name . '!'; + } +} diff --git a/src/Controllers/Controller.php b/src/Controllers/Controller.php new file mode 100644 index 0000000..aa579d0 --- /dev/null +++ b/src/Controllers/Controller.php @@ -0,0 +1,75 @@ +connection(); + $conn->getPdo()->exec('SELECT 1'); + + $response = Redis::ping(); + if ($response->getPayload() !== 'PONG') { + throw new \Exception('Redis ping failed'); + } + } catch (\Exception $e) { + Logger::emergency( + 'Healthcheck failed: ' . $e->getMessage(), + ['exception' => $e] + ); + + return JsonResponseFactory::createJsonResponse( + new ServerErrorResponse($e), + CodesEnum::SERVICE_UNAVAILABLE + ); + } + + return JsonResponseFactory::createJsonResponse( + new GenericResponse('Healthcheck OK') + ); + } +} diff --git a/src/Controllers/IndexController.php b/src/Controllers/IndexController.php new file mode 100644 index 0000000..51385bf --- /dev/null +++ b/src/Controllers/IndexController.php @@ -0,0 +1,66 @@ +getQueryParams()['name'] ?? 'Guest'); + $greeting = CommandBus::handle($command); + + return JsonResponseFactory::createJsonResponse(new GenericResponse('Server is running. ' . $greeting)); + } + + /** + * Handles the POST request for the index route. + * + * @throws \JsonException + */ + #[Guards\Jwt] + #[Guards\Scope(['post.index'])] + #[OA\Post(path: '/', security: [new TokenSecurity()], tags: ['Examples'])] + #[OA\Response( + response: '200', + description: 'An Example Response', + content: new OA\JsonContent(ref: '#/components/schemas/GenericResponse') + )] + #[UnauthorizedResponse] + public function post(ServerRequest $request): ResponseInterface + { + return JsonResponseFactory::createJsonResponse(new GenericResponse('POST request received')); + } +} diff --git a/src/Controllers/OpenApiController.php b/src/Controllers/OpenApiController.php new file mode 100644 index 0000000..85b5513 --- /dev/null +++ b/src/Controllers/OpenApiController.php @@ -0,0 +1,41 @@ +generate([ + __DIR__ . '/../Controllers', + __DIR__ . '/../Models', + __DIR__ . '/../Http/Responses', + ]); + + $response = new Response(); + + if ( + $request->getHeaderLine('Accept') === 'application/json' || + str_contains($request->getUri()->getPath(), '.json') + ) { + $response->getBody()->write($openapi->toJson()); + return $response->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write($openapi->toYaml()); + return $response->withHeader('Content-Type', 'application/x-yaml'); + } +} diff --git a/src/Docs/TokenSecurity.php b/src/Docs/TokenSecurity.php new file mode 100644 index 0000000..7376ab6 --- /dev/null +++ b/src/Docs/TokenSecurity.php @@ -0,0 +1,19 @@ +pushed = new Collection(); + $this->registerListeners(); + } + + /** + * @throws \Throwable + */ + public function __destruct() + { + foreach ($this->pushed as $event => $payload) { + $this->dispatch($event, $payload); + } + } + + /** + * Register event listeners based on the ListensFor attribute. + * + * @return void + */ + private function registerListeners(): void + { + // traverse the Listeners directory and register all listeners + $listenersPath = __DIR__ . '/Listeners'; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($listenersPath)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $relativePath = str_replace($listenersPath . '/', '', $file->getPathname()); + $className = self::LISTENERS_NAMESPACE . str_replace(['/', '.php'], ['\\', ''], $relativePath); + if (class_exists($className)) { + $reflectionClass = new \ReflectionClass($className); + $attributes = $reflectionClass->getAttributes(ListensFor::class); + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $eventClass = $instance->eventClass; + $this->listen($eventClass, new $className()); + } + } + } + } + } + + /** + * Register a listener for the given events. + * + * @param $events + * @param $listener + * @return void + */ + public function listen($events, $listener = null): void + { + $this->listeners[$events][] = $listener; + } + + /** + * Check if there are listeners for the given event. + * + * @param $eventName + * @return bool + */ + public function hasListeners($eventName): bool + { + return isset($this->listeners[$eventName]) && !empty($this->listeners[$eventName]); + } + + /** + * Subscribe a subscriber to the dispatcher. + * + * @param Arrayable $subscriber + * @return void + */ + public function subscribe($subscriber): void + { + $this->subscribers[] = $subscriber; + } + + /** + * Dispatch an event and halt on the first non-null response. + * + * @param $event + * @param array $payload + * @return array|null + * @throws \Throwable + */ + public function until($event, $payload = []): array|null + { + return $this->dispatch($event, $payload, true); + } + + /** + * Dispatch an event to its listeners. + * + * @param $event + * @param array $payload + * @param bool $halt + * @return array|null + * @throws \Throwable + */ + public function dispatch($event, $payload = [], $halt = false): array|null + { + if (is_object($event)) { + $eventClass = get_class($event); + } else { + $eventClass = $event; + } + + // Handle subscribers as a coroutine + $promise = coroutine(function () use ($event, $payload, $halt, $eventClass, &$responses) { + foreach ($this->subscribers as $subscriber) { + if (method_exists($subscriber, 'handle')) { + $response = $subscriber->handle($event, $payload); + $responses[$eventClass] = $response; + + if ($halt && $response !== null) { + return $responses; + } + } + } + + return null; + }); + + $listeners = $this->listeners[$eventClass] ?? null; + + // If no listeners, just await the subscriber promise + if ($listeners === null) { + return await($promise); + } + + $responses = []; + foreach ($listeners as $listener) { + $response = $listener($event, $payload); + $responses[$eventClass] = $response; + + if ($halt && $response !== null) { + return $response; + } + } + + // Await the subscriber promise and merge responses + $promiseResponses = await($promise); + + if (is_array($promiseResponses)) { + $responses = array_merge($responses, $promiseResponses); + } + + return $responses; + } + + /** + * Push an event to be dispatched later. + * + * @param $event + * @param array $payload + * @return void + */ + public function push($event, $payload = []): void + { + $this->pushed->put($event, $payload); + } + + /** + * Flush a pushed event, dispatching it if it exists. + * + * @param $event + * @return void + * @throws \Throwable + */ + public function flush($event): void + { + if ($this->pushed->has($event)) { + $payload = $this->pushed->get($event); + $this->dispatch($event, $payload); + $this->pushed->forget([$event]); + } + } + + /** + * Forget a pushed event without dispatching it. + * + * @param $event + * @return void + */ + public function forget($event): void + { + $this->pushed->forget([$event]); + } + + /** + * Forget all pushed events. + * + * @return void + */ + public function forgetPushed(): void + { + $this->pushed = new Collection(); + } + + public function toArray(): array + { + return $this->listeners; + } +} diff --git a/src/Events/Listeners/Database/Connected.php b/src/Events/Listeners/Database/Connected.php new file mode 100644 index 0000000..f7844e9 --- /dev/null +++ b/src/Events/Listeners/Database/Connected.php @@ -0,0 +1,35 @@ +connectionName]); + + return null; + } +} diff --git a/src/Events/Listeners/Listener.php b/src/Events/Listeners/Listener.php new file mode 100644 index 0000000..cbf29ea --- /dev/null +++ b/src/Events/Listeners/Listener.php @@ -0,0 +1,14 @@ + GreeterHandler::class, + ]; + + /** + * @throws \ReflectionException + */ + public function __construct() + { + Kernel::boot(); + } + + /** + * Starts the gRPC server + * + * @return int + */ + public function start(): int + { + $server = new Server(new Invoker(), [ + 'debug' => Config::get('app.dev_mode'), + ]); + + foreach (self::SERVICES as $interface => $handler) { + $server->registerService($interface, new $handler()); + } + + $server->serve(Worker::create()); + + return 0; + } +} diff --git a/src/GrpcHandlers/GreeterHandler.php b/src/GrpcHandlers/GreeterHandler.php new file mode 100644 index 0000000..3c124ab --- /dev/null +++ b/src/GrpcHandlers/GreeterHandler.php @@ -0,0 +1,25 @@ +getName()); + + $reply = new HelloReply(); + $reply->setMessage(CommandBus::handle($command)); + + return $reply; + } +} diff --git a/src/Helpers/Env.php b/src/Helpers/Env.php new file mode 100644 index 0000000..034b767 --- /dev/null +++ b/src/Helpers/Env.php @@ -0,0 +1,30 @@ + (bool) $env, + 'int', 'integer' => (int) $env, + 'float' => (float) $env, + default => (string) $env, + }; + } +} diff --git a/src/Helpers/Ulid.php b/src/Helpers/Ulid.php new file mode 100644 index 0000000..df328a2 --- /dev/null +++ b/src/Helpers/Ulid.php @@ -0,0 +1,22 @@ +getRandomness(); + } +} diff --git a/src/Helpers/Version.php b/src/Helpers/Version.php new file mode 100644 index 0000000..4b7bfc4 --- /dev/null +++ b/src/Helpers/Version.php @@ -0,0 +1,10 @@ +toArray(); + } + + return new Response( + status: $statusCode->value, + headers: [ + 'Content-Type' => 'application/json', + ], + body: json_encode($data, JSON_THROW_ON_ERROR) + ); + } +} diff --git a/src/Http/Middleware/CorsMiddleware.php b/src/Http/Middleware/CorsMiddleware.php new file mode 100644 index 0000000..d8f6c23 --- /dev/null +++ b/src/Http/Middleware/CorsMiddleware.php @@ -0,0 +1,72 @@ +getHeaderLine('Origin'); + $allowedOrigins = array_map( + 'trim', + explode( + ',', + Config::get('cors.allowed_origins') + ) + ); + + $allowOrigin = in_array($origin, $allowedOrigins, true) + ? $origin + : null; + + if ($request->getMethod() === 'OPTIONS') { + $response = new Response(204); + } else { + $response = $handler->handle($request); + } + + if ($allowOrigin === null) { + return $response; // Do not add CORS headers if origin is not allowed. + } + + $response = $response + ->withHeader('Access-Control-Allow-Origin', $allowOrigin) + ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + ->withHeader( + 'Access-Control-Allow-Headers', + $request->getHeaderLine('Access-Control-Request-Headers') + ?: 'Content-Type, Authorization' + ); + + if (Config::get('cors.allow_credentials') === true) { + $response = $response->withHeader('Access-Control-Allow-Credentials', 'true'); + } + + $maxAge = Config::get('cors.max_age') ?: '86400'; // Use correct configuration key. + + return $response->withHeader('Access-Control-Max-Age', $maxAge); + } +} diff --git a/src/Http/Middleware/JwtMiddleware.php b/src/Http/Middleware/JwtMiddleware.php new file mode 100644 index 0000000..2a27099 --- /dev/null +++ b/src/Http/Middleware/JwtMiddleware.php @@ -0,0 +1,322 @@ +extractRouteCallable($handler); + if ($callable === null) { + return $handler->handle($request); + } + + /** @var Controller $class */ + [$class, $method] = $callable; + + if (class_exists($class::class)) { + $reflectionClass = new \ReflectionClass($class); + + if ($reflectionClass->hasMethod($method)) { + $reflectionMethod = $reflectionClass->getMethod($method); + // Read `Jwt` attribute on the controller method. + $attributes = $reflectionMethod->getAttributes(Jwt::class); + + // If no `Jwt` attribute, do not enforce auth here. + if (empty($attributes)) { + return $handler->handle($request); + } + + // Extract Bearer token from Authorization header. + $token = str_replace('Bearer ', '', $request->getHeaderLine('Authorization')); + + if (empty($token)) { + return JsonResponseFactory::createJsonResponse([ + 'status_code' => 401, + 'message' => 'Unauthorized: Missing token', + ], CodesEnum::UNAUTHORIZED); + } + + // Aggregate required issuers and audience from attributes. + $requiredIssuers = []; + $requiredAudience = ''; + + foreach ($attributes as $attribute) { + /** @var Jwt $jwtInstance */ + $jwtInstance = $attribute->newInstance(); + + if ($jwtInstance->getAudience() !== '') { + $requiredAudience = $jwtInstance->getAudience(); + } + + $requiredIssuers[] = $jwtInstance->getIssuer(); + } + + try { + // Parse and validate the token with signature, time, issuer and audience constraints. + $jwt = new JwtFacade()->parse( + $token, + $this->getSignedWith($token), + Config::get('jwt.strict_validation') ? + new StrictValidAt(new WrapperClock(Carbon::now())) : + new LooseValidAt(new WrapperClock(Carbon::now())), + new IssuedBy(...$requiredIssuers), + new PermittedFor($requiredAudience) + ); + } catch (RequiredConstraintsViolated $exception) { + // Collect human-readable violations to return to the client. + $violations = []; + foreach ($exception->violations() as $violation) { + $violations[] = $violation->getMessage(); + } + + return JsonResponseFactory::createJsonResponse([ + 'status_code' => CodesEnum::UNAUTHORIZED->value, + 'message' => 'Unauthorized: Invalid token', + 'errors' => $violations + ], CodesEnum::UNAUTHORIZED); + } catch (InvalidTokenStructure) { + // Token could not be parsed due to malformed structure. + return JsonResponseFactory::createJsonResponse([ + 'status_code' => CodesEnum::UNAUTHORIZED->value, + 'message' => 'Unauthorized: Invalid token', + ], CodesEnum::UNAUTHORIZED); + } catch (GuzzleException | \RuntimeException) { + return JsonResponseFactory::createJsonResponse([ + 'status_code' => CodesEnum::INTERNAL_SERVER_ERROR->value, + 'message' => 'Token validation service unavailable or unknown error', + ], CodesEnum::INTERNAL_SERVER_ERROR); + } + + // Expose all token claims as request attributes for downstream consumers. + foreach ($jwt->claims()->all() as $item => $value) { + $request = $request->withAttribute($item, $value); + } + } + } + + return $handler->handle($request); + } + + /** + * Build the signature validation constraint from configured key. + * + * - If the configured key content includes the string `PUBLIC KEY`, use RSA SHA-256. + * - Otherwise assume an HMAC SHA-256 shared secret. + * - Supports raw key strings or `file://` paths. + * + * @return SignedWith Signature constraint used during JWT parsing. + * + * @throws \RuntimeException When no signing key is configured. + * @throws \JsonException + */ + private function getSignedWith(string $token): SignedWith + { + $keyConfig = Config::get('jwt.signing_key'); + + if ($keyConfig === null) { + throw new \RuntimeException('JWT signing key is not configured.'); + } + + // file:// path to key + if (str_starts_with($keyConfig, 'file://')) { + $key = InMemory::file(substr($keyConfig, 7)); + // openid jwks url + } elseif (str_contains($keyConfig, '.well-known/')) { + $jwt = explode('.', $token); + if (count($jwt) !== 3) { + throw new InvalidTokenStructure('Invalid JWT structure for JWKS key retrieval.'); + } + $header = json_decode(base64_decode($jwt[0]), true, 512, JSON_THROW_ON_ERROR); + $keyId = $header['kid'] ?? '0'; // Default to '0' if no kid present + $key = $this->getJwksKey($keyConfig, $keyId); + } else { + $key = InMemory::plainText($keyConfig); + } + + // Heuristic: if PEM public key content is detected, use RSA; otherwise use HMAC. + if (str_contains($key->contents(), 'PUBLIC KEY')) { + return new SignedWith(new Sha256(), $key); + } + + return new SignedWith(new Hmac256(), $key); + } + + private function getJwksKey(string $url, string $keyId): Key + { + $cached = Redis::get('jwks_key_' . $keyId); + if ($cached !== null) { + return InMemory::plainText($cached); + } + + $openIdConfig = Guzzle::get($url); + $body = json_decode($openIdConfig->getBody()->getContents(), true, JSON_THROW_ON_ERROR); + $jwksUri = $body['jwks_uri'] ?? ''; + if (empty($jwksUri)) { + throw new \RuntimeException('JWKS URI not found in OpenID configuration.'); + } + + $jwksResponse = Guzzle::get($jwksUri); + $jwksBody = json_decode( + $jwksResponse->getBody()->getContents(), + true, + JSON_THROW_ON_ERROR + ); + + // For simplicity, we take the first key in the JWKS. + $firstKey = array_filter( + $jwksBody['keys'], + fn($key) => $key['kid'] === $keyId + )[0] ?? $jwksBody['keys'][0] ?? null; + + if (empty($firstKey)) { + throw new \RuntimeException('No matching key found in JWKS for key ID: ' . $keyId); + } + + $n = $firstKey['n']; + $e = $firstKey['e']; + $publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" . + chunk_split(base64_encode($this->convertJwkToPem($n, $e)), 64) . + "-----END PUBLIC KEY-----\n"; + + Redis::set('jwks_key_' . $keyId, $publicKeyPem, 'EX', 3600); + + return InMemory::plainText($publicKeyPem); + } + + /** + * Build a DER-encoded SubjectPublicKeyInfo from JWK 'n' and 'e'. + * Returns raw DER bytes; caller base64-encodes and wraps with PEM headers. + */ + private function convertJwkToPem(string $n, string $e): string + { + $modulus = $this->base64UrlDecode($n); + $exponent = $this->base64UrlDecode($e); + + $derN = $this->derEncodeInteger($modulus); + $derE = $this->derEncodeInteger($exponent); + + // RSAPublicKey (PKCS#1): SEQUENCE { n INTEGER, e INTEGER } + $rsaPublicKey = $this->derEncodeSequence($derN . $derE); + + // AlgorithmIdentifier for rsaEncryption: 1.2.840.113549.1.1.1 with NULL + $algId = hex2bin('300d06092a864886f70d0101010500'); + + // SubjectPublicKey (SPKI) BIT STRING, 0 unused bits + RSAPublicKey + $subjectPublicKey = $this->derEncodeBitString($rsaPublicKey); + + // SubjectPublicKeyInfo: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING } + return $this->derEncodeSequence($algId . $subjectPublicKey); + } + + private function base64UrlDecode(string $data): string + { + $data = strtr($data, '-_', '+/'); + $pad = strlen($data) % 4; + if ($pad) { + $data .= str_repeat('=', 4 - $pad); + } + return base64_decode($data); + } + + private function derEncodeLength(int $len): string + { + if ($len < 0x80) { + return chr($len); + } + $bytes = ''; + while ($len > 0) { + $bytes = chr($len & 0xFF) . $bytes; + $len >>= 8; + } + return chr(0x80 | strlen($bytes)) . $bytes; + } + + private function derEncodeInteger(string $bytes): string + { + // Remove leading zeroes + $bytes = ltrim($bytes, "\x00"); + if ($bytes === '') { + $bytes = "\x00"; + } + // Ensure positive INTEGER (prepend 0x00 if MSB set) + if ((ord($bytes[0]) & 0x80) !== 0) { + $bytes = "\x00" . $bytes; + } + return "\x02" . $this->derEncodeLength(strlen($bytes)) . $bytes; + } + + private function derEncodeSequence(string $bytes): string + { + return "\x30" . $this->derEncodeLength(strlen($bytes)) . $bytes; + } + + private function derEncodeBitString(string $bytes): string + { + // 0 unused bits + data + $payload = "\x00" . $bytes; + return "\x03" . $this->derEncodeLength(strlen($payload)) . $payload; + } +} diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php new file mode 100644 index 0000000..1c9f054 --- /dev/null +++ b/src/Http/Middleware/Middleware.php @@ -0,0 +1,71 @@ +getMiddlewareStack()); + + if ($lastSegment === null) { + return null; + } + + // Obtain the callable associated with the route. + $callable = $lastSegment->getCallable(); + $class = null; + $method = null; + + // Handle array callable: [object|class-string, 'method'] + if (is_array($callable) && count($callable) === 2) { + [$class, $method] = $callable; + } elseif (is_string($callable)) { + // Handle string callable: 'ClassName::methodName' + [$class, $method] = explode('::', $callable); + } + + return [$class, $method]; + } +} diff --git a/src/Http/Middleware/ScopeMiddleware.php b/src/Http/Middleware/ScopeMiddleware.php new file mode 100644 index 0000000..334afde --- /dev/null +++ b/src/Http/Middleware/ScopeMiddleware.php @@ -0,0 +1,117 @@ +extractRouteCallable($handler); + if ($callable === null) { + // If no callable is available, delegate to the next handler. + return $handler->handle($request); + } + + /** @var Controller $class Controller instance resolved from the route. */ + [$class, $method] = $callable; + + // Ensure the controller exists and the method is defined before reflecting. + if (class_exists($class::class)) { + $reflectionClass = new \ReflectionClass($class); + + if ($reflectionClass->hasMethod($method)) { + $reflectionMethod = $reflectionClass->getMethod($method); + + // Fetch all Scope attributes declared on the method. + $attributes = $reflectionMethod->getAttributes(Scope::class); + $requireAllAttributes = $reflectionMethod->getAttributes(RequireAllScopes::class); + + if (empty($attributes)) { + // No scope attributes; delegate to the next handler. + return $handler->handle($request); + } + + $requiredScopes = []; + $userScopes = []; + $requireAll = false; + + foreach ($attributes as $attribute) { + /** @var Scope $scopeInstance Concrete Scope attribute instance. */ + $scopeInstance = $attribute->newInstance(); + $requiredScopes = array_merge($requiredScopes, $scopeInstance->getScopes()); + + // If any attribute requires all scopes, set the flag. + $requireAll = $requireAll || !empty($requireAllAttributes); + + $scopes = $request->getAttribute($scopeInstance->getClaim()); + if (!is_array($scopes)) { + // If user scopes are not an array, treat as no scopes provided. + $scopes = explode($scopeInstance->getSeparator(), (string) $scopes); + } + + $userScopes = array_merge( + $userScopes, + $scopes + ); + } + + $userScopes = array_unique($userScopes); + + // Deny if any required scope is missing from the user's scopes. + if ( + (!$requireAll && array_intersect($userScopes, $requiredScopes) === []) || + ($requireAll && array_diff($requiredScopes, $userScopes) !== []) + ) { + return JsonResponseFactory::createJsonResponse([ + 'error' => 'insufficient_scope', + 'error_description' => + 'The request requires higher privileges than provided by the access token.' + ], CodesEnum::FORBIDDEN); + } + } + } + + // All checks passed; continue down the middleware pipeline. + return $handler->handle($request); + } +} diff --git a/src/Http/Responses/GenericResponse.php b/src/Http/Responses/GenericResponse.php new file mode 100644 index 0000000..7755702 --- /dev/null +++ b/src/Http/Responses/GenericResponse.php @@ -0,0 +1,29 @@ + $this->message, + ]; + } +} diff --git a/src/Http/Responses/NotFoundResponse.php b/src/Http/Responses/NotFoundResponse.php new file mode 100644 index 0000000..7a165bb --- /dev/null +++ b/src/Http/Responses/NotFoundResponse.php @@ -0,0 +1,38 @@ + 'The requested resource ' . $this->uri . ' was not found.', + 'context' => $this->context, + ]; + } +} diff --git a/src/Http/Responses/ServerErrorResponse.php b/src/Http/Responses/ServerErrorResponse.php new file mode 100644 index 0000000..7abe0a1 --- /dev/null +++ b/src/Http/Responses/ServerErrorResponse.php @@ -0,0 +1,56 @@ + $this->e->getCode() != 0 ? + $this->e->getCode() : + CodesEnum::INTERNAL_SERVER_ERROR->value, + 'message' => $this->e->getMessage(), + 'file' => $this->e->getFile(), + 'line' => $this->e->getLine(), + 'trace' => $this->e->getTrace(), + 'context' => $this->context, + ]; + } + + return [ + 'code' => $this->e->getCode() != 0 ? + $this->e->getCode() : + CodesEnum::INTERNAL_SERVER_ERROR->value, + 'message' => 'An internal server error occurred.', + ]; + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 0000000..04f7c39 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,97 @@ +bind(SWConfig::class, function () { + return SWConfig::load(__DIR__ . '/../config.php'); + }); + + foreach (self::$serviceProviders as $serviceProvider) { + if (class_exists($serviceProvider)) { + $provider = new $serviceProvider($container); + if ($provider instanceof ServiceProvider) { + $provider->register(); + } else { + throw new \RuntimeException(sprintf( + 'Service provider %s is not an instance of ServiceProvider.', + $serviceProvider + )); + } + } else { + throw new \RuntimeException(sprintf('Service provider %s not found.', $serviceProvider)); + } + } + + self::bootModelCapsule(); + } + + /** + * Bootstraps the model capsule for database connections. + * + * This method sets up the database connection using the Eloquent ORM. + * It retrieves the database configuration from the Config facade and + * initializes the Eloquent capsule manager. + * + * @return void + */ + private static function bootModelCapsule(): void + { + $capsule = new Manager(); + $capsule->setEventDispatcher(Dispatcher::getFacadeRoot()); + $capsule->addConnection(Config::get('db')); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + } +} diff --git a/src/Log/Logger.php b/src/Log/Logger.php new file mode 100644 index 0000000..69127dc --- /dev/null +++ b/src/Log/Logger.php @@ -0,0 +1,236 @@ + + */ + private array $levels = [ + LogLevel::EMERGENCY => 0, + LogLevel::ALERT => 1, + LogLevel::CRITICAL => 2, + LogLevel::ERROR => 3, + LogLevel::WARNING => 4, + LogLevel::NOTICE => 5, + LogLevel::INFO => 6, + LogLevel::DEBUG => 7, + ]; + + /** + * Create a new Logger. + * + * @param string $level Minimum level to log (PSR-3 level string). Messages with + * a higher numeric value in `$levels` will be ignored. + * + * @param resource | null $streamOutput Optional stream handler for Monolog. + * + * The default is `LogLevel::DEBUG` (log everything). + * + * If `$_SERVER['RR_RPC']` is set, an RPC connection will be attempted at + * $_SERVER['RR_RPC'] and a RoadRunner RPC logger will be used. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __construct( + private readonly string $level = LogLevel::DEBUG, + $streamOutput = null, + ) { + if (isset($_SERVER['RR_RPC'])) { + $this->rpcLogger = RoadRunnerLogger::getFacadeRoot(); + } + + $this->monologLogger = new \Monolog\Logger('app_logger'); + $formatter = new JsonFormatter(); + $stream = $streamOutput ?? 'php://stdout'; + $this->monologLogger->pushHandler(new StreamHandler($stream)->setFormatter($formatter)); + } + + /** + * System is unusable. + * + * @param \Stringable|string $message + * @param array $context + */ + public function emergency(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::EMERGENCY, $message, $context); + } + + /** + * Action must be taken immediately. + * + * @param \Stringable|string $message + * @param array $context + */ + public function alert(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::ALERT, $message, $context); + } + + /** + * Critical conditions. + * + * @param \Stringable|string $message + * @param array $context + */ + public function critical(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::CRITICAL, $message, $context); + } + + /** + * Runtime errors that do not require immediate action but should typically be logged and monitored. + * + * @param \Stringable|string $message + * @param array $context + */ + public function error(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::ERROR, $message, $context); + } + + /** + * Exceptional occurrences that are not errors. + * + * @param \Stringable|string $message + * @param array $context + */ + public function warning(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::WARNING, $message, $context); + } + + /** + * Normal but significant events. + * + * @param \Stringable|string $message + * @param array $context + */ + public function notice(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::NOTICE, $message, $context); + } + + /** + * Interesting events. + * + * @param \Stringable|string $message + * @param array $context + */ + public function info(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::INFO, $message, $context); + } + + /** + * Detailed debug information. + * + * @param \Stringable|string $message + * @param array $context + */ + public function debug(\Stringable|string $message, array $context = []): void + { + $this->log(LogLevel::DEBUG, $message, $context); + } + + /** + * Logs with an arbitrary level. + * + * Behavior details: + * - If the provided `$level` maps to a numeric value greater than the configured + * minimum level, the message is discarded (filtered). + * - If an RPC logger is available, the message is forwarded to the RPC logger + * using a method chosen by level (debug, info, warning, error). + * - Otherwise, the message is written to Monolog using the numeric mapping. + * + * Notes: + * - `$level` should be a PSR-3 level string (values defined in `Psr\Log\LogLevel`). + * - If an unknown level string is passed, accessing `$this->levels[$level]` may + * trigger a PHP notice or undefined index. Ensure callers use valid PSR-3 levels. + * + * @param mixed $level PSR-3 log level (string) + * @param \Stringable|string $message + * @param array $context + */ + public function log($level, \Stringable|string $message, array $context = []): void + { + if (isset($this->levels[$level]) && $this->levels[$level] > $this->levels[$this->level]) { + return; + } + + if ($this->rpcLogger) { + switch ($level) { + case LogLevel::DEBUG: + $this->rpcLogger->debug((string)$message, $context); + break; + case LogLevel::NOTICE: + case LogLevel::INFO: + $this->rpcLogger->info((string)$message, $context); + break; + case LogLevel::WARNING: + $this->rpcLogger->warning((string)$message, $context); + break; + case LogLevel::CRITICAL: + case LogLevel::ERROR: + case LogLevel::ALERT: + case LogLevel::EMERGENCY: + $this->rpcLogger->error((string)$message, $context); + break; + default: + $this->rpcLogger->log($level, (string)$message, $context); + break; + } + + return; + } + + $this->monologLogger->log($this->levels[$level], (string)$message, $context); + } +} diff --git a/src/Models/Model.php b/src/Models/Model.php new file mode 100644 index 0000000..0ef857f --- /dev/null +++ b/src/Models/Model.php @@ -0,0 +1,17 @@ + 'datetime', + ]; + + protected $hidden = [ + 'password', + ]; + + protected $fillable = [ + 'first_name', + 'last_name', + 'email', + 'password', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->attributes['id'] = $this->attributes['id'] ?? Ulid::generate(); + } + + public function getFullNameAttribute(): string + { + return "$this->first_name $this->last_name"; + } + + public function getFormattedEmailAttribute(): string + { + return sprintf( + '%s <%s>', + $this->getFullNameAttribute(), + strtolower($this->email) + ); + } +} diff --git a/src/Services/Facade.php b/src/Services/Facade.php new file mode 100644 index 0000000..e9e883f --- /dev/null +++ b/src/Services/Facade.php @@ -0,0 +1,278 @@ +resolved($accessor) === true) { + $callback(static::getFacadeRoot(), static::$container); + } + + static::$container->afterResolving($accessor, function ($service, $app) use ($callback) { + $callback($service, $app); + }); + } + + /** + * Initiate a partial mock on the facade. + * + * @return MockInterface + */ + public static function partialMock(): MockInterface + { + $name = static::getFacadeAccessor(); + + $mock = static::isMock() + ? static::$resolvedInstance[$name] + : static::createFreshMockInstance(); + + return $mock->makePartial(); + } + + /** + * Initiate a mock expectation on the facade. + * + * @return Expectation|ExpectationInterface + */ + public static function shouldReceive(): Mockery\Expectation | Mockery\ExpectationInterface + { + $name = static::getFacadeAccessor(); + + $mock = static::isMock() + ? static::$resolvedInstance[$name] + : static::createFreshMockInstance(); + + return $mock->shouldReceive(...func_get_args()); + } + + /** + * Initiate a mock expectation on the facade. + * + * @return Expectation|ExpectationInterface + */ + public static function expects(): Mockery\Expectation | Mockery\ExpectationInterface + { + $name = static::getFacadeAccessor(); + + $mock = static::isMock() + ? static::$resolvedInstance[$name] + : static::createFreshMockInstance(); + + return $mock->expects(...func_get_args()); + } + + /** + * Create a fresh mock instance for the given class. + * + * @return MockInterface|LegacyMockInterface + */ + protected static function createFreshMockInstance(): MockInterface | LegacyMockInterface + { + return tap(static::createMock(), function ($mock) { + static::swap($mock); + + $mock->shouldAllowMockingProtectedMethods(); + }); + } + + /** + * Create a fresh mock instance for the given class. + * + * @return MockInterface + */ + protected static function createMock(): MockInterface + { + $class = static::getMockableClass(); + + return $class ? Mockery::mock($class) : Mockery::mock(); + } + + /** + * Determines whether a mock is set as the instance of the facade. + * + * @return bool + */ + protected static function isMock(): bool + { + $name = static::getFacadeAccessor(); + + return isset(static::$resolvedInstance[$name]) && + static::$resolvedInstance[$name] instanceof LegacyMockInterface; + } + + /** + * Get the mockable class for the bound instance. + * + * @return string|null + */ + protected static function getMockableClass(): ?string + { + if ($root = static::getFacadeRoot()) { + return get_class($root); + } + + return null; + } + + /** + * Hotswap the underlying instance behind the facade. + * + * @param mixed $instance + * @return void + */ + public static function swap(mixed $instance): void + { + static::$resolvedInstance[static::getFacadeAccessor()] = $instance; + + if (isset(static::$container)) { + static::$container->instance(static::getFacadeAccessor(), $instance); + } + } + + /** + * Get the root object behind the facade. + * + * @return mixed + */ + public static function getFacadeRoot(): mixed + { + return static::resolveFacadeInstance(static::getFacadeAccessor()); + } + + /** + * Get the registered name of the component. + * + * @return string + * + * @throws \RuntimeException + */ + protected static function getFacadeAccessor(): string + { + throw new \RuntimeException('Facade does not implement getFacadeAccessor method.'); + } + + /** + * Resolve the facade root instance from the container. + * + * @param string $name + * @return mixed + */ + protected static function resolveFacadeInstance(string $name): mixed + { + if (isset(static::$resolvedInstance[$name])) { + return static::$resolvedInstance[$name]; + } + + if (static::$container) { + if (static::$cached) { + return static::$resolvedInstance[$name] = static::$container[$name]; + } + + return static::$container[$name]; + } + + return null; + } + + /** + * Clear a resolved facade instance. + * + * @param string $name + * @return void + */ + public static function clearResolvedInstance(string $name): void + { + unset(static::$resolvedInstance[$name]); + } + + /** + * Clear all of the resolved instances. + * + * @return void + */ + public static function clearResolvedInstances(): void + { + static::$resolvedInstance = []; + } + + /** + * Get the application instance behind the facade. + */ + public static function getFacadeContainer(): ?Container + { + return static::$container; + } + + /** + * Set the application instance. + * + * @param Container | null $container + * @return void + */ + public static function setFacadeContainer(Container | null $container): void + { + static::$container = $container; + } + + /** + * Handle dynamic, static calls to the object. + * + * @param string $method + * @param array $args + * @return mixed + * + * @throws \RuntimeException + */ + public static function __callStatic(string $method, array $args) + { + $instance = static::getFacadeRoot(); + + if (! $instance) { + throw new \RuntimeException('A facade root has not been set.'); + } + + return $instance->$method(...$args); + } +} diff --git a/src/Services/Facades/Broker.php b/src/Services/Facades/Broker.php new file mode 100644 index 0000000..822dfc7 --- /dev/null +++ b/src/Services/Facades/Broker.php @@ -0,0 +1,29 @@ +has(Logger::class) === false) { + $rpc = RPC::create($_SERVER['RR_RPC']); + $logger = new Logger($rpc); + $container->bind(static::getFacadeAccessor(), function () use ($logger) { + return $logger; + }); + + return $logger; + } + + return $container->get(Logger::class); + } + + protected static function getFacadeAccessor(): string + { + return Logger::class; + } +} diff --git a/src/Services/ServiceProviders/BrokerServiceProvider.php b/src/Services/ServiceProviders/BrokerServiceProvider.php new file mode 100644 index 0000000..03d4d13 --- /dev/null +++ b/src/Services/ServiceProviders/BrokerServiceProvider.php @@ -0,0 +1,54 @@ +app->singleton(Broker::class, function (): Broker { + $configName = Config::get('queue.broker'); + $brokerConfig = Config::get('queue.broker_config.' . $configName) ?? []; + + $brokerClass = Broker::BROKER_TYPES[$configName] ?? null; + + if ($brokerClass && class_exists($brokerClass)) { + return new $brokerClass($brokerConfig); + } + + throw new \RuntimeException("Broker class $brokerClass does not exist."); + }); + } +} diff --git a/src/Services/ServiceProviders/CommandBusProvider.php b/src/Services/ServiceProviders/CommandBusProvider.php new file mode 100644 index 0000000..93567b8 --- /dev/null +++ b/src/Services/ServiceProviders/CommandBusProvider.php @@ -0,0 +1,38 @@ +app->singleton(CommandBus::class, function () { + return new CommandBus([ + new CommandHandlerMiddleware( + new ClassNameExtractor(), + new AttributeLocator(), + new InvokeInflector() + ), + ]); + }); + } +} diff --git a/src/Services/ServiceProviders/DispatcherServiceProvider.php b/src/Services/ServiceProviders/DispatcherServiceProvider.php new file mode 100644 index 0000000..43435d0 --- /dev/null +++ b/src/Services/ServiceProviders/DispatcherServiceProvider.php @@ -0,0 +1,28 @@ +app->singleton(Dispatcher::class, function () { + return new Dispatcher(); + }); + } +} diff --git a/src/Services/ServiceProviders/LoggerServiceProvider.php b/src/Services/ServiceProviders/LoggerServiceProvider.php new file mode 100644 index 0000000..8285ea3 --- /dev/null +++ b/src/Services/ServiceProviders/LoggerServiceProvider.php @@ -0,0 +1,29 @@ +app->singleton(Logger::class, function () { + return new Logger(Config::get('app.log_level')); + }); + } +} diff --git a/src/Services/ServiceProviders/RedisServiceProvider.php b/src/Services/ServiceProviders/RedisServiceProvider.php new file mode 100644 index 0000000..555e84d --- /dev/null +++ b/src/Services/ServiceProviders/RedisServiceProvider.php @@ -0,0 +1,37 @@ +app->singleton(Client::class, function () { + return new Client([ + 'scheme' => 'tcp', + 'host' => Config::get('redis.host'), + 'port' => Config::get('redis.port'), + 'database' => Config::get('redis.database'), + 'password' => Config::get('redis.password'), + ]); + }); + } +} diff --git a/tests/Attributes/Guards/JwtTest.php b/tests/Attributes/Guards/JwtTest.php new file mode 100644 index 0000000..b4f426a --- /dev/null +++ b/tests/Attributes/Guards/JwtTest.php @@ -0,0 +1,49 @@ +getAttributes(Jwt::class); + $this->assertCount(1, $attributes); + + /** @var Jwt $instance */ + $instance = $attributes[0]->newInstance(); + $this->assertEquals('default-audience', $instance->getAudience()); + $this->assertEquals('default-issuer', $instance->getIssuer()); + } + + public function testGetsClassFromCustom(): void + { + $reflection = new \ReflectionClass(TestClassSpecific::class); + $attributes = $reflection->getAttributes(Jwt::class); + $this->assertCount(1, $attributes); + + /** @var Jwt $instance */ + $instance = $attributes[0]->newInstance(); + $this->assertEquals('custom-audience', $instance->getAudience()); + $this->assertEquals('custom-issuer', $instance->getIssuer()); + } +} + +#[Jwt] +class TestClass // @codingStandardsIgnoreLine +{ +} + +#[Jwt('custom-issuer', 'custom-audience')] +class TestClassSpecific // @codingStandardsIgnoreLine +{ +} diff --git a/tests/Attributes/Guards/ScopeTest.php b/tests/Attributes/Guards/ScopeTest.php new file mode 100644 index 0000000..638d477 --- /dev/null +++ b/tests/Attributes/Guards/ScopeTest.php @@ -0,0 +1,43 @@ +getAttributes(Scope::class); + $this->assertCount(1, $attributes); + + /** @var Scope $instance */ + $instance = $attributes[0]->newInstance(); + $this->assertEquals(['read:users'], $instance->getScopes()); + } + + public function testGetsClassFromCustom(): void + { + $reflection = new \ReflectionClass(TestClassMultiple::class); + $attributes = $reflection->getAttributes(Scope::class); + $this->assertCount(1, $attributes); + + /** @var Scope $instance */ + $instance = $attributes[0]->newInstance(); + $this->assertEquals(['read:users', 'write:users'], $instance->getScopes()); + } +} + +#[Scope(['read:users', 'write:users'])] +class TestClassMultiple // @codingStandardsIgnoreLine +{ +} + +#[Scope(['read:users'])] +class TestClassSingle // @codingStandardsIgnoreLine +{ +} diff --git a/tests/Attributes/HandlesMessageTest.php b/tests/Attributes/HandlesMessageTest.php new file mode 100644 index 0000000..42823d9 --- /dev/null +++ b/tests/Attributes/HandlesMessageTest.php @@ -0,0 +1,25 @@ +getAttributes(HandlesMessage::class); + $this->assertCount(1, $attributes); + + /** @var HandlesMessage $instance */ + $instance = $attributes[0]->newInstance(); + $this->assertEquals('Siteworxpro\Tests\Attributes\TestClass', $instance->getMessageClass()); + } +} diff --git a/tests/CommandBus/AttributeLocatorTest.php b/tests/CommandBus/AttributeLocatorTest.php new file mode 100644 index 0000000..2b71b30 --- /dev/null +++ b/tests/CommandBus/AttributeLocatorTest.php @@ -0,0 +1,36 @@ + ExampleHandler::class, + ]; + + public function testResolvesFiles(): void + { + $attributeLocator = new AttributeLocator(); + + foreach (self::HANDLERS as $command => $handler) { + $class = $attributeLocator->getHandlerForCommand($command); + $this->assertInstanceOf($handler, $class); + } + } + + public function testThrowsOnCannotResolve(): void + { + $attributeLocator = new AttributeLocator(); + + $this->expectException(CanNotInvokeHandlerException::class); + $attributeLocator->getHandlerForCommand('NonExistentCommand'); + } +} diff --git a/tests/CommandBus/Handlers/ExampleHandlerTest.php b/tests/CommandBus/Handlers/ExampleHandlerTest.php new file mode 100644 index 0000000..7d33bd6 --- /dev/null +++ b/tests/CommandBus/Handlers/ExampleHandlerTest.php @@ -0,0 +1,32 @@ +assertEquals('test payload', $command->getName()); + + $handler = new ExampleHandler(); + $result = $handler($command); + $this->assertEquals('Hello, test payload!', $result); + } + + public function testThrowsException(): void + { + $class = new readonly class extends Command + { + }; + + $this->expectException(\TypeError::class); + $handler = new ExampleHandler(); + $handler($class); + } +} diff --git a/tests/Controllers/AbstractController.php b/tests/Controllers/AbstractController.php new file mode 100644 index 0000000..084c4c4 --- /dev/null +++ b/tests/Controllers/AbstractController.php @@ -0,0 +1,16 @@ +expectException(\League\Route\Http\Exception\NotFoundException::class); + $testClass->get($this->getMockRequest()); + } + + public function testNotFoundExceptionPost() + { + $testClass = new TestClass(); + + $this->expectException(\League\Route\Http\Exception\NotFoundException::class); + $testClass->post($this->getMockRequest()); + } + + public function testNotFoundExceptionPut() + { + $testClass = new TestClass(); + + $this->expectException(\League\Route\Http\Exception\NotFoundException::class); + $testClass->put($this->getMockRequest()); + } + + public function testNotFoundExceptionDelete() + { + $testClass = new TestClass(); + + $this->expectException(\League\Route\Http\Exception\NotFoundException::class); + $testClass->delete($this->getMockRequest()); + } + + public function testNotFoundExceptionPatch() + { + $testClass = new TestClass(); + + $this->expectException(\League\Route\Http\Exception\NotFoundException::class); + $testClass->patch($this->getMockRequest()); + } +} + +class TestClass extends Controller // phpcs:ignore +{ +} diff --git a/tests/Controllers/IndexControllerTest.php b/tests/Controllers/IndexControllerTest.php new file mode 100644 index 0000000..842c55d --- /dev/null +++ b/tests/Controllers/IndexControllerTest.php @@ -0,0 +1,48 @@ +assertTrue(true); + + $this->getContainer()->bind(CommandBus::class, function () { + return \Mockery::mock(CommandBus::class) + ->shouldReceive('handle') + ->andReturn('Hello World') + ->getMock(); + }); + + $controller = new IndexController(); + + $response = $controller->get($this->getMockRequest()); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"message":"Server is running. Hello World"}', (string)$response->getBody()); + } + + /** + * @throws \JsonException + */ + public function testPost(): void + { + $this->assertTrue(true); + + $controller = new IndexController(); + + $response = $controller->post($this->getMockRequest()); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"message":"POST request received"}', (string)$response->getBody()); + } +} diff --git a/tests/Controllers/OpenApiControllerTest.php b/tests/Controllers/OpenApiControllerTest.php new file mode 100644 index 0000000..6eaf399 --- /dev/null +++ b/tests/Controllers/OpenApiControllerTest.php @@ -0,0 +1,33 @@ +getMockRequest('/.well-known/openapi.yaml'); + $controller = new OpenApiController(); + + $response = $controller->get($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertStringContainsString('openapi: 3.0.0', (string)$response->getBody()); + } + + public function testBuildsJson(): void + { + $request = $this->getMockRequest(uri: '/.well-known/openapi.json'); + $controller = new OpenApiController(); + + $response = $controller->get($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); + $this->assertNotFalse(json_decode($response->getBody()->getContents())); + } +} diff --git a/tests/Events/DispatcherTest.php b/tests/Events/DispatcherTest.php new file mode 100644 index 0000000..8cdc422 --- /dev/null +++ b/tests/Events/DispatcherTest.php @@ -0,0 +1,177 @@ +getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $eventFired = false; + $dispatcher->listen('TestEvent', function ($event) use (&$eventFired) { + $this->assertEquals('TestEvent', $event); + $eventFired = true; + }); + + $dispatcher->dispatch('TestEvent'); + $this->assertTrue($eventFired, 'The TestEvent listener was not fired.'); + } + + /** + * @throws BindingResolutionException + */ + public function testPushesEvents() + { + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $eventsFired = 0; + $dispatcher->listen('PushedEvent1', function ($event) use (&$eventsFired) { + $eventsFired++; + $this->assertEquals('PushedEvent1', $event); + }); + + $dispatcher->listen('PushedEvent2', function ($event) use (&$eventsFired) { + $eventsFired++; + $this->assertEquals('PushedEvent2', $event); + }); + + $dispatcher->push('PushedEvent1'); + $dispatcher->push('PushedEvent2'); + + unset($dispatcher); // Trigger destructor + $this->assertEquals(2, $eventsFired); + } + + /** + * @throws BindingResolutionException + * @throws \Throwable + */ + public function testFlushEvent(): void + { + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $eventFired = false; + $dispatcher->listen('FlushEvent', function ($event) use (&$eventFired) { + $this->assertEquals('FlushEvent', $event); + $eventFired = true; + }); + + $dispatcher->push('FlushEvent'); + $dispatcher->flush('FlushEvent'); + + $this->assertTrue($eventFired, 'The FlushEvent listener was not fired.'); + } + + /** + * @throws BindingResolutionException + */ + public function testHasListeners(): void + { + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $this->assertFalse( + $dispatcher->hasListeners( + 'NonExistentEvent' + ), + 'Expected no listeners for NonExistentEvent.' + ); + + $dispatcher->listen('ExistingEvent', function () { + // Listener logic + }); + + $this->assertTrue( + $dispatcher->hasListeners( + 'ExistingEvent' + ), + 'Expected listeners for ExistingEvent.' + ); + } + + /** + * @throws BindingResolutionException + * @throws \Throwable + */ + public function testForgetEvent(): void + { + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $eventFired = false; + $dispatcher->listen('ForgetEvent', function ($event) use (&$eventFired) { + $this->assertEquals('ForgetEvent', $event); + $eventFired = true; + }); + + $dispatcher->push('ForgetEvent'); + $dispatcher->forget('ForgetEvent'); + + unset($dispatcher); // Trigger destructor + + $this->assertFalse($eventFired, 'The ForgetEvent listener was fired but should have been forgotten.'); + } + + /** + * @throws BindingResolutionException + */ + public function testForgetPushed() + { + $this->expectNotToPerformAssertions(); + + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $dispatcher->listen('EventToForget', function () { + $this->fail('The EventToForget listener was fired but should have been forgotten.'); + }); + + $dispatcher->push('EventToForget'); + $dispatcher->forgetPushed(); + + unset($dispatcher); // Trigger destructor + } + + /** + * @throws BindingResolutionException + */ + public function testToArray(): void + { + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + + $dispatcher->listen('ArrayEvent', function () { + // Listener logic + }); + + $arrayRepresentation = $dispatcher->toArray(); + $this->assertArrayHasKey('ArrayEvent', $arrayRepresentation); + } + + /** + * @throws BindingResolutionException + * @throws \Throwable + */ + public function testSubscriber() + { + $subscriber = $this->getMockBuilder('Siteworxpro\App\Events\Subscribers\Subscriber') + ->onlyMethods(['handle']) + ->getMock(); + + $subscriber->expects($this->once()) + ->method('handle') + ->with('SubscribedEvent', []) + ->willReturn(null); + + $dispatcher = $this->getContainer()->make('Siteworxpro\App\Events\Dispatcher'); + $dispatcher->subscribe($subscriber); + + $dispatcher->dispatch('SubscribedEvent'); + } +} diff --git a/tests/Events/Listeners/ConnectedTest.php b/tests/Events/Listeners/ConnectedTest.php new file mode 100644 index 0000000..7c5a3a2 --- /dev/null +++ b/tests/Events/Listeners/ConnectedTest.php @@ -0,0 +1,47 @@ +bind(Logger::class, fn() => $logger); + } + + public function testHandlesEvent() + { + $this->expectNotToPerformAssertions(); + + $connectedEvent = $this->createMock(ConnectionEstablished::class); + $listener = new Connected(); + + $listener->__invoke($connectedEvent); + } + + public function testThrowsException() + { + $this->expectException(\TypeError::class); + $listener = new Connected(); + $listener->__invoke(new \stdClass()); + } +} diff --git a/tests/Facades/AbstractFacade.php b/tests/Facades/AbstractFacade.php new file mode 100644 index 0000000..e224979 --- /dev/null +++ b/tests/Facades/AbstractFacade.php @@ -0,0 +1,32 @@ +getFacadeClass(); + + $this->assertTrue( + method_exists($class, 'getFacadeAccessor'), + sprintf('The class %s must implement the method getFacadeAccessor.', $class) + ); + + $facade = $class::getFacadeRoot(); + + $this->assertNotNull( + $facade, + sprintf('The facade %s is not properly initialized.', $this->getConcrete()) + ); + } +} diff --git a/tests/Facades/DispatcherTest.php b/tests/Facades/DispatcherTest.php new file mode 100644 index 0000000..ade0fd9 --- /dev/null +++ b/tests/Facades/DispatcherTest.php @@ -0,0 +1,20 @@ +getContainer()->bind(CommandBus::class, function () { + return \Mockery::mock(CommandBus::class) + ->shouldReceive('handle') + ->andReturn('Hello World') + ->getMock(); + }); + + $request = new HelloRequest(); + $request->setName('World'); + + $context = \Mockery::mock(ContextInterface::class); + + $handler = new GreeterHandler(); + $response = $handler->SayHello($context, $request); + $this->assertEquals('Hello World', $response->getMessage()); + } +} diff --git a/tests/Helpers/EnvTest.php b/tests/Helpers/EnvTest.php new file mode 100644 index 0000000..4b46710 --- /dev/null +++ b/tests/Helpers/EnvTest.php @@ -0,0 +1,46 @@ +assertSame('example', $result); + } + + public function testGetReturnsDefaultIfKeyNotSet(): void + { + putenv('TEST_KEY'); // Unset the environment variable + $result = Env::get('TEST_KEY', 'default_value'); + $this->assertSame('default_value', $result); + } + + public function testGetCastsToBoolean(): void + { + putenv('TEST_KEY=true'); + $result = Env::get('TEST_KEY', null, 'bool'); + $this->assertTrue($result); + } + + public function testGetCastsToInteger(): void + { + putenv('TEST_KEY=123'); + $result = Env::get('TEST_KEY', null, 'int'); + $this->assertSame(123, $result); + } + + public function testGetCastsToFloat(): void + { + putenv('TEST_KEY=123.45'); + $result = Env::get('TEST_KEY', null, 'float'); + $this->assertSame(123.45, $result); + } +} diff --git a/tests/Helpers/UlidTest.php b/tests/Helpers/UlidTest.php new file mode 100644 index 0000000..592d1f4 --- /dev/null +++ b/tests/Helpers/UlidTest.php @@ -0,0 +1,19 @@ +assertIsString($ulid); + $this->assertEquals(16, strlen($ulid)); + } +} diff --git a/tests/Http/JsonResponseFactoryTest.php b/tests/Http/JsonResponseFactoryTest.php new file mode 100644 index 0000000..db41b2d --- /dev/null +++ b/tests/Http/JsonResponseFactoryTest.php @@ -0,0 +1,50 @@ + 'value']; + $statusCode = CodesEnum::OK; + + $response = JsonResponseFactory::createJsonResponse($data, $statusCode); + + $this->assertSame($statusCode->value, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame(json_encode($data), (string) $response->getBody()); + } + + /** + * @throws \JsonException + */ + public function testCreateJsonResponseHandlesEmptyData(): void + { + $data = []; + $statusCode = CodesEnum::NO_CONTENT; + + $response = JsonResponseFactory::createJsonResponse($data, $statusCode); + + $this->assertSame($statusCode->value, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame(json_encode($data), (string) $response->getBody()); + } + + public function testCreateJsonResponseThrowsExceptionOnInvalidData(): void + { + $this->expectException(\JsonException::class); + + $data = ["invalid" => "\xB1\x31"]; + JsonResponseFactory::createJsonResponse($data); + } +} diff --git a/tests/Http/Middleware/CorsMiddlewareTest.php b/tests/Http/Middleware/CorsMiddlewareTest.php new file mode 100644 index 0000000..4eecdd5 --- /dev/null +++ b/tests/Http/Middleware/CorsMiddlewareTest.php @@ -0,0 +1,83 @@ +with('cors.allowed_origins') + ->andReturn('https://example.com,https://another.com'); + + Config::shouldReceive('get')->with('cors.allow_credentials')->andReturn(false); + Config::shouldReceive('get')->with('cors.max_age')->andReturn(''); + + $middleware = new CorsMiddleware(); + $request = new ServerRequest('GET', '/')->withHeader('Origin', 'https://example.com'); + $handler = $this->mockHandler(new Response(200)); + + $response = $middleware->process($request, $handler); + + $this->assertEquals('https://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + + public function testBlocksUnconfiguredOrigin(): void + { + Config::shouldReceive('get') + ->with('cors.allowed_origins') + ->andReturn('https://example.com,https://another.com'); + + $middleware = new CorsMiddleware(); + $request = new ServerRequest('GET', '/')->withHeader('Origin', 'https://unauthorized.com'); + $handler = $this->mockHandler(new Response(200)); + + $response = $middleware->process($request, $handler); + + $this->assertEmpty($response->getHeaderLine('Access-Control-Allow-Origin')); + } + + public function testHandlesOptionsRequest(): void + { + Config::shouldReceive('get')->with('cors.allowed_origins')->andReturn('https://example.com'); + Config::shouldReceive('get')->with('cors.allow_credentials')->andReturn(false); + Config::shouldReceive('get')->with('cors.max_age')->andReturn('86400'); + + $middleware = new CorsMiddleware(); + $request = new ServerRequest('OPTIONS', '/')->withHeader('Origin', 'https://example.com'); + $handler = $this->mockHandler(new Response(200)); + + $response = $middleware->process($request, $handler); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertEquals('86400', $response->getHeaderLine('Access-Control-Max-Age')); + } + + public function testAddsAllowCredentialsHeader(): void + { + Config::shouldReceive('get') + ->with('cors.allowed_origins') + ->andReturn('https://example.com'); + + Config::shouldReceive('get')->with('cors.allowed_origins')->andReturn('https://example.com'); + Config::shouldReceive('get')->with('cors.allow_credentials')->andReturn(true); + Config::shouldReceive('get')->with('cors.max_age')->andReturn('86400'); + + $middleware = new CorsMiddleware(); + $request = new ServerRequest('GET', '/')->withHeader('Origin', 'https://example.com'); + $handler = $this->mockHandler(new Response(200)); + + $response = $middleware->process($request, $handler); + + $this->assertEquals('true', $response->getHeaderLine('Access-Control-Allow-Credentials')); + } +} diff --git a/tests/Http/Middleware/JwtMiddlewareTest.php b/tests/Http/Middleware/JwtMiddlewareTest.php new file mode 100644 index 0000000..76eb9c3 --- /dev/null +++ b/tests/Http/Middleware/JwtMiddlewareTest.php @@ -0,0 +1,366 @@ +shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testIgnoresJwtAttributeButNoToken() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testInvalidToken() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . 'invalid_token_string'); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'Unauthorized: Invalid token', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenButWrongAud() + { + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token is not allowed to be used by this audience', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenButWrongIss() + { + Config::set('jwt.audience', 'https://client-app.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token was not issued by the given issuers', + $response->getBody()->getContents() + ); + } + + /** + * @throws \JsonException + */ + public function testJwtAttributeWithTokenWithDiffIssuer() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://different-issuer.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::UNAUTHORIZED->value, $response->getStatusCode()); + $this->assertStringContainsString( + 'The token was not issued by the given issuers', + $response->getBody()->getContents() + ); + } + + public function testJwtAttributeWithToken() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://api.my-awesome-app.io'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwt()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testJwtFromJwkEndpoint() + { + Config::set('jwt.audience', 'https://client-app.io'); + Config::set('jwt.issuer', 'https://api.my-awesome-app.io'); + + Redis::partialMock()->shouldReceive('get')->andReturn(null); + Redis::shouldReceive('set')->andReturn('OK'); + Guzzle::partialMock()->shouldReceive('get') + ->with('https://test.com/.well-known/openid-configuration') + ->andReturn(new Response(200, [], json_encode([ + 'jwks_uri' => 'https://test.com/keys' + ], JSON_THROW_ON_ERROR))); + + Guzzle::shouldReceive('get') + ->with('https://test.com/keys') + ->andReturn(new Response(200, [], self::TEST_JWKS_JSON)); + + Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration'); + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \JsonException + */ + public function testCatchesInvalidJwksUrl() + { + Config::set('jwt.signing_key', 'https://test.com/.well-known/openid-configuration'); + Redis::partialMock()->shouldReceive('get')->andReturn(null); + Redis::shouldReceive('set')->andReturn('OK'); + Guzzle::partialMock()->shouldReceive('get') + ->with('https://test.com/.well-known/openid-configuration') + ->andReturn(new Response(200, [], json_encode([], JSON_THROW_ON_ERROR))); + + + + $class = $this->getClass(); + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getJwtRsa()); + $middleware = new JwtMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::INTERNAL_SERVER_ERROR->value, $response->getStatusCode()); + } + + private function getJwtRsa(): string + { + $key = InMemory::plainText(self::TEST_RSA_PRIVATE_KEY); + $signer = new \Lcobucci\JWT\Signer\Rsa\Sha256(); + $token = new JwtFacade()->issue( + $signer, + $key, + static fn( + Builder $builder, + DateTimeImmutable $issuedAt + ): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ->expiresAt($issuedAt->modify('+10 minutes')) + ); + + return $token->toString(); + } + + private function getJwt(): string + { + $key = InMemory::plainText(self::TEST_SIGNING_KEY); + $signer = new Sha256(); + + $token = new JwtFacade()->issue( + $signer, + $key, + static fn( + Builder $builder, + DateTimeImmutable $issuedAt + ): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ->expiresAt($issuedAt->modify('+10 minutes')) + ); + + return $token->toString(); + } +} diff --git a/tests/Http/Middleware/Middleware.php b/tests/Http/Middleware/Middleware.php new file mode 100644 index 0000000..cfb69b5 --- /dev/null +++ b/tests/Http/Middleware/Middleware.php @@ -0,0 +1,32 @@ +response = $response; + } + + public function handle( + ServerRequestInterface $request + ): ResponseInterface { + return $this->response; + } + }; + } +} diff --git a/tests/Http/Middleware/ScopeMiddlewareTest.php b/tests/Http/Middleware/ScopeMiddlewareTest.php new file mode 100644 index 0000000..b8f0642 --- /dev/null +++ b/tests/Http/Middleware/ScopeMiddlewareTest.php @@ -0,0 +1,111 @@ +shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/'); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * @throws \ReflectionException + * @throws \JsonException + */ + public function testAllowsWithScope() + { + $class = new class { + public function getCallable(): array + { + return [ $this, 'index' ]; + } + + #[Scope(['admin'])] + public function index() + { + // Dummy method for testing + } + }; + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $handler + ->shouldReceive('handle') + ->once() + ->andReturn(new Response(200)); + + $request = new ServerRequest('GET', '/')->withAttribute('scope', ['admin', 'user']); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::OK->value, $response->getStatusCode()); + } + + /** + * @throws \ReflectionException + * @throws \JsonException + */ + public function testDisallowsWithScope() + { + $class = new class { + public function getCallable(): array + { + return [ $this, 'index' ]; + } + + #[Scope(['admin'])] + public function index() + { + // Dummy method for testing + } + }; + + $handler = \Mockery::mock(Dispatcher::class); + $handler->shouldReceive('getMiddlewareStack') + ->andReturn([$class]); + + $request = new ServerRequest('GET', '/'); + $middleware = new ScopeMiddleware(); + $response = $middleware->process($request, $handler); + $this->assertEquals(CodesEnum::FORBIDDEN->value, $response->getStatusCode()); + } +} diff --git a/tests/Http/Responses/NotFoundResponseTest.php b/tests/Http/Responses/NotFoundResponseTest.php new file mode 100644 index 0000000..fcbd627 --- /dev/null +++ b/tests/Http/Responses/NotFoundResponseTest.php @@ -0,0 +1,21 @@ + 'value']); + + $expected = [ + 'message' => 'The requested resource /api/resource was not found.', + 'context' => ['key' => 'value'], + ]; + + $this->assertEquals($expected, $response->toArray()); + } +} diff --git a/tests/Http/Responses/ServerErrorResponseTest.php b/tests/Http/Responses/ServerErrorResponseTest.php new file mode 100644 index 0000000..f9ac467 --- /dev/null +++ b/tests/Http/Responses/ServerErrorResponseTest.php @@ -0,0 +1,93 @@ + 'data_processing']); + + $expected = [ + 'code' => 500, + 'message' => 'A Test Error occurred.', + 'context' => [ + 'operation' => 'data_processing' + ], + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTrace(), + ]; + + $this->assertEquals($expected, $response->toArray()); + } + } + + public function testToArrayNotInDevMode(): void + { + Config::set('app.dev_mode', false); + + try { + throw new \Exception('A Test Error occurred.'); + } catch (\Exception $exception) { + $response = new ServerErrorResponse($exception); + + $expected = [ + 'code' => 500, + 'message' => 'An internal server error occurred.', + ]; + + $this->assertEquals($expected, $response->toArray()); + } + } + + public function testToArrayIfCodeIsSet(): void + { + Config::set('app.dev_mode', false); + + try { + throw new \Exception('A Test Error occurred.', 1234); + } catch (\Exception $exception) { + $response = new ServerErrorResponse($exception); + + $expected = [ + 'code' => 1234, + 'message' => 'An internal server error occurred.', + ]; + + $this->assertEquals($expected, $response->toArray()); + } + } + + public function testToArrayIfCodeIsSetDevMode(): void + { + Config::set('app.dev_mode', true); + + try { + throw new \Exception('A Test Error occurred.', 1234); + } catch (\Exception $exception) { + $response = new ServerErrorResponse($exception); + + $expected = [ + 'code' => 1234, + 'message' => 'A Test Error occurred.', + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTrace(), + 'context' => [], + ]; + + $this->assertEquals($expected, $response->toArray()); + } + } +} diff --git a/tests/Log/LoggerRpcTest.php b/tests/Log/LoggerRpcTest.php new file mode 100644 index 0000000..a69067c --- /dev/null +++ b/tests/Log/LoggerRpcTest.php @@ -0,0 +1,163 @@ +expectNotToPerformAssertions(); + + $_SERVER['RR_RPC'] = 'tcp://127.0.0.1:6001'; + + $mock = Mockery::mock(LoggerInterface::class); + $mock->expects('debug') + ->with('message', ['key' => 'value']) + ->times(1); + + LoggerFacade::getFacadeContainer() + ->bind(RRLogger::class, function () use ($mock) { + return $mock; + }); + + $inputBuffer = fopen('php://memory', 'r+'); + $logger = new Logger(LogLevel::DEBUG, $inputBuffer); + $logger->debug('message', ['key' => 'value']); + + $mock->shouldHaveReceived('debug'); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsDebugMessageWhenLevelIsInfoNotice(): void + { + $this->expectNotToPerformAssertions(); + + $_SERVER['RR_RPC'] = 'tcp://127.0.0.1:6001'; + + $mock = Mockery::mock(LoggerInterface::class); + $mock->expects('info') + ->with('message', ['key' => 'value']) + ->times(2); + + LoggerFacade::getFacadeContainer() + ->bind(RRLogger::class, function () use ($mock) { + return $mock; + }); + + $inputBuffer = fopen('php://memory', 'r+'); + $logger = new Logger(LogLevel::DEBUG, $inputBuffer); + $logger->info('message', ['key' => 'value']); + $logger->notice('message', ['key' => 'value']); + + $mock->shouldHaveReceived('info')->times(2); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsDebugMessageWhenLevelIsInfoWarning(): void + { + $this->expectNotToPerformAssertions(); + + $_SERVER['RR_RPC'] = 'tcp://127.0.0.1:6001'; + + $mock = Mockery::mock(LoggerInterface::class); + $mock->expects('warning') + ->with('message', ['key' => 'value']) + ->times(1); + + LoggerFacade::getFacadeContainer() + ->bind(RRLogger::class, function () use ($mock) { + return $mock; + }); + + $inputBuffer = fopen('php://memory', 'r+'); + $logger = new Logger(LogLevel::DEBUG, $inputBuffer); + $logger->warning('message', ['key' => 'value']); + + $mock->shouldHaveReceived('warning'); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsDebugMessageWhenLevelIsInfoError(): void + { + $this->expectNotToPerformAssertions(); + + $_SERVER['RR_RPC'] = 'tcp://127.0.0.1:6001'; + + $mock = Mockery::mock(LoggerInterface::class); + $mock->expects('error') + ->with('message', ['key' => 'value']) + ->times(4); + + LoggerFacade::getFacadeContainer() + ->bind(RRLogger::class, function () use ($mock) { + return $mock; + }); + + $inputBuffer = fopen('php://memory', 'r+'); + $logger = new Logger(LogLevel::DEBUG, $inputBuffer); + $logger->error('message', ['key' => 'value']); + $logger->critical('message', ['key' => 'value']); + $logger->alert('message', ['key' => 'value']); + $logger->emergency('message', ['key' => 'value']); + + $mock->shouldHaveReceived('error')->times(4); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsLog(): void + { + $this->expectNotToPerformAssertions(); + + $_SERVER['RR_RPC'] = 'tcp://127.0.0.1:6001'; + + $mock = Mockery::mock(LoggerInterface::class); + $mock->expects('log') + ->with('notaloglevel', 'message', ['key' => 'value']); + + LoggerFacade::getFacadeContainer() + ->bind(RRLogger::class, function () use ($mock) { + return $mock; + }); + + $inputBuffer = fopen('php://memory', 'r+'); + $logger = new Logger(LogLevel::DEBUG, $inputBuffer); + $logger->log('notaloglevel', 'message', ['key' => 'value']); + + $mock->shouldHaveReceived('log')->times(1); + } +} diff --git a/tests/Log/LoggerTest.php b/tests/Log/LoggerTest.php new file mode 100644 index 0000000..e618d67 --- /dev/null +++ b/tests/Log/LoggerTest.php @@ -0,0 +1,229 @@ +getLoggerWithBuffer($level); + $logger->$level('message', ['key' => 'value']); + $output = $this->getContents($inputBuffer); + + $this->assertNotEmpty($output); + $decoded = json_decode($output, true); + $this->assertEquals('message', $decoded['message']); + $this->assertEquals('value', $decoded['context']['key']); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + private function testLogLevelEmpty(string $configLevel, string $logLevel): void + { + [$logger, $inputBuffer] = $this->getLoggerWithBuffer($configLevel); + $logger->$logLevel('message', ['key' => 'value']); + $output = $this->getContents($inputBuffer); + + $this->assertEmpty($output); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsDebugMessageWhenLevelIsDebug(): void + { + $this->testLogLevel(LogLevel::DEBUG); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsInfoMessageWhenLevelIsInfo(): void + { + $this->testLogLevel(LogLevel::INFO); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsWarningMessageWhenLevelIsWarning(): void + { + $this->testLogLevel(LogLevel::WARNING); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsErrorMessageWhenLevelIsError(): void + { + $this->testLogLevel(LogLevel::ERROR); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsCriticalMessageWhenLevelIsCritical(): void + { + $this->testLogLevel(LogLevel::CRITICAL); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsAlertMessageWhenLevelIsAlert(): void + { + $this->testLogLevel(LogLevel::ALERT); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsEmergencyMessageWhenLevelIsEmergency(): void + { + $this->testLogLevel(LogLevel::EMERGENCY); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsNoticeMessageWhenLevelIsNotice(): void + { + $this->testLogLevel(LogLevel::NOTICE); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testDoesNotLogWhenMinimumLevelIsInfo(): void + { + $this->testLogLevelEmpty(LogLevel::INFO, LogLevel::DEBUG); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testDoesNotLogWhenMinimumLevelIsWarning(): void + { + $this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::INFO); + $this->testLogLevelEmpty(LogLevel::WARNING, LogLevel::DEBUG); + } + + /** + * @throws NotFoundExceptionInterface + * @throws ContainerExceptionInterface + */ + public function testDoesNotLogWhenMinimumLevelIsError(): void + { + $this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::DEBUG); + $this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::INFO); + $this->testLogLevelEmpty(LogLevel::ERROR, LogLevel::WARNING); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testDoesNotLogWhenMinimumLevelIsNotice(): void + { + $this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::DEBUG); + $this->testLogLevelEmpty(LogLevel::NOTICE, LogLevel::INFO); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsMessageWithEmptyContext(): void + { + [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); + + $logger->info('Message without context'); + $output = $this->getContents($buffer); + + $this->assertNotEmpty($output); + $decoded = json_decode($output, true); + $this->assertEquals('Message without context', $decoded['message']); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsMessageWithComplexContext(): void + { + [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); + + $logger->info('Complex context', [ + 'user_id' => 123, + 'nested' => ['key' => 'value'], + 'array' => [1, 2, 3] + ]); + $output = $this->getContents($buffer); + + $this->assertNotEmpty($output); + $decoded = json_decode($output, true); + $this->assertEquals(123, $decoded['context']['user_id']); + $this->assertEquals('value', $decoded['context']['nested']['key']); + } + + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogsStringableMessage(): void + { + [$logger, $buffer] = $this->getLoggerWithBuffer(LogLevel::INFO); + + $stringable = new class implements \Stringable { + public function __toString(): string + { + return 'Stringable message'; + } + }; + + $logger->info($stringable); + $output = $this->getContents($buffer); + $this->assertNotEmpty($output); + $decoded = json_decode($output, true); + $this->assertEquals('Stringable message', $decoded['message']); + } +} diff --git a/tests/Models/UserTest.php b/tests/Models/UserTest.php new file mode 100644 index 0000000..7661c43 --- /dev/null +++ b/tests/Models/UserTest.php @@ -0,0 +1,30 @@ +first_name = 'John'; + $user->last_name = 'Doe'; + + $this->assertEquals('John Doe', $user->full_name); + } + + public function testFormatsEmail(): void + { + $user = new User(); + $user->first_name = 'Jane'; + $user->last_name = 'Smith'; + $user->email = 'jane.smith@email.com'; + + $this->assertEquals('Jane Smith ', $user->formatted_email); + } +} diff --git a/tests/ServiceProviders/AbstractServiceProvider.php b/tests/ServiceProviders/AbstractServiceProvider.php new file mode 100644 index 0000000..ffd7f38 --- /dev/null +++ b/tests/ServiceProviders/AbstractServiceProvider.php @@ -0,0 +1,43 @@ +getProviderClass(); + + /** @var ServiceProvider $providerClass */ + $provider = new $providerClass($container); + + $this->assertInstanceOf($providerClass, $provider); + $provider->register(); + + $abstract = $provider->provides()[0]; + $concrete = get_class($container->make($abstract)); + + $this->assertTrue($container->bound($abstract), "The $abstract is not bound in the container."); + $this->assertNotNull($container->make($abstract), "The $abstract could not be resolved."); + + $this->assertInstanceOf( + $concrete, + $container->make($abstract), + "The $abstract is not an instance of $concrete." + ); + } +} diff --git a/tests/ServiceProviders/CommandBusServiceProviderTest.php b/tests/ServiceProviders/CommandBusServiceProviderTest.php new file mode 100644 index 0000000..f899bc2 --- /dev/null +++ b/tests/ServiceProviders/CommandBusServiceProviderTest.php @@ -0,0 +1,15 @@ +getContainer(); + + $container->bind(SWConfig::class, function () { + return SWConfig::load(__DIR__ . '/../config.php'); + }); + } + + protected function tearDown(): void + { + Config::clearResolvedInstances(); + Facade::setFacadeContainer(null); + Mockery::close(); + } +}