Initial commit

This commit is contained in:
2025-12-29 16:27:01 +00:00
committed by Siteworx Pro Gitea
commit 23f2b6432b
147 changed files with 14731 additions and 0 deletions

14
.allowed-licenses.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use Lendable\ComposerLicenseChecker\LicenseConfigurationBuilder;
return new LicenseConfigurationBuilder()
->addLicenses(
'MIT',
'BSD-2-Clause',
'BSD-3-Clause',
'Apache-2.0',
)
->build();

192
.dev/docker-compose.yml Normal file
View File

@@ -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

83
.dev/ssl/localhost.crt Normal file
View File

@@ -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-----

6
.dev/ssl/localhost.key Normal file
View File

@@ -0,0 +1,6 @@
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDBrpJYaCMqgu490fpZoIphGVspE33v3JwyD9B55HwSX/jykySs9NTOv
68YndzE9LNCgBwYFK4EEACKhZANiAATPo12p4GAjotNSDG5QFyeohWROL0gQTmSj
UAsBs/JCouY8l7VcM+lNugzJQUiviJ4YThUJQkXD/aMPC7ZUHWmNl5xiq91988XV
GSVYS4v8KIPiOIoFN/sbsp9RPwYBGcc=
-----END EC PRIVATE KEY-----

14
.dev/ssl/traefik.yml Normal file
View File

@@ -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

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.idea/
.DS_Store
vendor/
.phpunit.cache/
tests/

View File

@@ -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

268
.gitea/workflows/tests.yml Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.idea/
.DS_Store
vendor/
.phpunit.cache/
tests/reports/

35
.rr.yaml Normal file
View File

@@ -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}

View File

@@ -0,0 +1,10 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name=".dev: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="sourceFilePath" value=".dev/docker-compose.yml" />
</settings>
</deployment>
<method v="2" />
</configuration>
</component>

8
.run/All.run.xml Normal file
View File

@@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All" type="ComposerRunConfigurationType" factoryName="Composer Script">
<option name="commandLineParameters" value="" />
<option name="pathToComposerJson" value="$PROJECT_DIR$/composer.json" />
<option name="script" value="tests:all" />
<method v="2" />
</configuration>
</component>

8
.run/Lint_fix.run.xml Normal file
View File

@@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Lint:fix" type="ComposerRunConfigurationType" factoryName="Composer Script">
<option name="commandLineParameters" value="" />
<option name="pathToComposerJson" value="$PROJECT_DIR$/composer.json" />
<option name="script" value="tests:lint:fix" />
<method v="2" />
</configuration>
</component>

11
.run/Main.run.xml Normal file
View File

@@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Main" type="PHPUnitRunConfigurationType" factoryName="PHPUnit">
<CommandLine>
<PhpTestInterpreterSettings>
<option name="interpreterName" value="composer-runtime" />
</PhpTestInterpreterSettings>
</CommandLine>
<TestRunner configuration_file="$PROJECT_DIR$/phpunit.xml" coverage_engine="PCov" scope="XML" use_alternative_configuration_file="true" />
<method v="2" />
</configuration>
</component>

63
Dockerfile Normal file
View File

@@ -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"]

150
README.md Normal file
View File

@@ -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.
```

4
bin/migrate.sh Executable file
View File

@@ -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

19
bin/pcov.sh Executable file
View File

@@ -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

24
bin/xdebug.sh Executable file
View File

@@ -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

11
cli.php Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/local/bin/php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Siteworxpro\App\Cli\App;
$cliApp = new App();
exit($cliApp->run());

75
composer.json Normal file
View File

@@ -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"
}
]
}

6851
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

88
config.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
use Siteworxpro\App\Helpers\Env;
return [
'app' => [
'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', ''),
]
]
]
];

View File

@@ -0,0 +1 @@
drop table if exists users;

View File

@@ -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()
);

View File

@@ -0,0 +1,25 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\GPBMetadata;
class Example
{
public static $is_initialized = false;
public static function initOnce() {
$pool = \Google\Protobuf\Internal\DescriptorPool::getGeneratedPool();
if (static::$is_initialized == true) {
return;
}
$pool->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;
}
}

View File

@@ -0,0 +1,22 @@
<?php
# Generated by the protocol buffer compiler (roadrunner-server/grpc). DO NOT EDIT!
# source: protos/example.proto
namespace GRPC\Greeter;
use Spiral\RoadRunner\GRPC;
interface GreeterInterface extends GRPC\ServiceInterface
{
// GRPC specific service name.
public const NAME = "helloworld.Greeter";
/**
* @param GRPC\ContextInterface $ctx
* @param HelloRequest $in
* @return HelloReply
*
* @throws GRPC\Exception\InvokeException
*/
public function SayHello(GRPC\ContextInterface $ctx, HelloRequest $in): HelloReply;
}

View File

@@ -0,0 +1,61 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\Greeter;
use Google\Protobuf\Internal\GPBType;
use Google\Protobuf\Internal\GPBUtil;
use Google\Protobuf\RepeatedField;
/**
* The response message containing the greetings
*
* Generated from protobuf message <code>helloworld.HelloReply</code>
*/
class HelloReply extends \Google\Protobuf\Internal\Message
{
/**
* Generated from protobuf field <code>string message = 1;</code>
*/
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 <code>string message = 1;</code>
* @return string
*/
public function getMessage()
{
return $this->message;
}
/**
* Generated from protobuf field <code>string message = 1;</code>
* @param string $var
* @return $this
*/
public function setMessage($var)
{
GPBUtil::checkString($var, True);
$this->message = $var;
return $this;
}
}

View File

@@ -0,0 +1,61 @@
<?php
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: protos/example.proto
namespace GRPC\Greeter;
use Google\Protobuf\Internal\GPBType;
use Google\Protobuf\Internal\GPBUtil;
use Google\Protobuf\RepeatedField;
/**
* The request message containing the user's name.
*
* Generated from protobuf message <code>helloworld.HelloRequest</code>
*/
class HelloRequest extends \Google\Protobuf\Internal\Message
{
/**
* Generated from protobuf field <code>string name = 1;</code>
*/
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 <code>string name = 1;</code>
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Generated from protobuf field <code>string name = 1;</code>
* @param string $var
* @return $this
*/
public function setName($var)
{
GPBUtil::checkString($var, True);
$this->name = $var;
return $this;
}
}

3
generated/README.md Normal file
View File

@@ -0,0 +1,3 @@
### Note to Developers
Only generated files are allowed in this directory.
Please do not add any other files here manually.

14
grpc-worker.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
use Siteworxpro\App\Grpc;
require __DIR__ . '/vendor/autoload.php';
try {
$server = new Grpc();
exit($server->start());
} catch (\Exception $e) {
echo $e->getMessage();
exit(1);
}

187
makefile Normal file
View File

@@ -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

8
migrations.Dockerfile Normal file
View File

@@ -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"]

3
phpstan.neon Normal file
View File

@@ -0,0 +1,3 @@
parameters:
ignoreErrors:
- '#Static call to instance method#'

24
phpunit.xml Normal file
View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

23
protos/example.proto Normal file
View File

@@ -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;
}

14
server.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
use Siteworxpro\App\Api;
require __DIR__ . '/vendor/autoload.php';
try {
$server = new Api();
$server->startServer();
} catch (JsonException $e) {
echo $e->getMessage();
exit(1);
}

134
src/Api.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App;
use League\Route\Http\Exception\MethodNotAllowedException;
use League\Route\Http\Exception\NotFoundException;
use League\Route\RouteGroup;
use League\Route\Router;
use Nyholm\Psr7\Factory\Psr17Factory;
use Siteworxpro\App\Controllers\HealthcheckController;
use Siteworxpro\App\Controllers\IndexController;
use Siteworxpro\App\Controllers\OpenApiController;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Middleware\CorsMiddleware;
use Siteworxpro\App\Http\Middleware\JwtMiddleware;
use Siteworxpro\App\Http\Middleware\ScopeMiddleware;
use Siteworxpro\App\Http\Responses\NotFoundResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Logger;
use Spiral\RoadRunner\Http\PSR7Worker;
use Spiral\RoadRunner\Worker;
/**
* Class Server
*
* This class represents the main server application.
* It handles incoming HTTP requests, routes them to the appropriate handlers,
* and manages the server lifecycle.
*
* @package Siteworxpro\App
*/
class Api
{
/**
* @var Router The router instance for handling routes.
*/
protected Router $router;
/**
* @var PSR7Worker The PSR-7 worker instance for handling HTTP requests.
*/
protected PSR7Worker $worker;
/**
* @throws \ReflectionException
*/
public function __construct()
{
Kernel::boot();
$this->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))
);
}
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
abstract class Broker implements BrokerInterface
{
public const array BROKER_TYPES = [
'redis' => Redis::class,
'rabbitmq' => RabbitMQ::class,
'kafka' => Kafka::class,
'sqs' => Sqs::class,
];
public function __construct(protected $config = [])
{
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
interface BrokerInterface
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void;
public function consume(Queue $queue): Message | 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;
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use RdKafka\Conf;
use RdKafka\Exception;
use RdKafka\KafkaConsumer;
use RdKafka\Producer;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class Kafka extends Broker
{
private Producer $producer;
private KafkaConsumer $consumer;
public function __construct($config = [])
{
parent::__construct($config);
$conf = new Conf();
$conf->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
{
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class RabbitMQ extends Broker
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
// TODO: Implement publish() method.
}
public function consume(Queue $queue): Message | null
{
return null;
}
public function acknowledge(Queue $queue, Message $message): void
{
// TODO: Implement acknowledge() method.
}
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.
}
}

190
src/Async/Brokers/Redis.php Normal file
View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Predis\Client;
use Predis\Command\RawCommand;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Helpers\Ulid;
class Redis extends Broker
{
private Client $client;
private string $consumerId;
private string $consumerGroup;
private const string CONSUMER_ID_PREFIX = 'consumer-group:';
private const string QUEUE_PREFIX = 'queue:';
private array $queueNames = [];
public function __construct($config = [])
{
parent::__construct($config);
$this->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.
}
}

36
src/Async/Brokers/Sqs.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Brokers;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Async\Messages\Message;
class Sqs extends Broker
{
public function publish(Queue $queue, Message $message, ?int $delay = null): void
{
// TODO: Implement publish() method.
}
public function consume(Queue $queue): Message | null
{
return null;
}
public function acknowledge(Queue $queue, Message $message): void
{
// TODO: Implement acknowledge() method.
}
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.
}
}

173
src/Async/Consumer.php Normal file
View File

@@ -0,0 +1,173 @@
<?php
declare(ticks=1);
namespace Siteworxpro\App\Async;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facades\Broker;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Long-running process that listens to queues, pops messages, and dispatches them to handlers.
*/
class Consumer
{
private static bool $shutDown = false;
/** @var array<string,string> */
private const array QUEUES = [
'default' => Queues\DefaultQueue::class,
];
/** @var Queue[] */
private array $queues = [];
/** @var array<string, string[]> 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;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Async\Messages\Message;
interface HandlerInterface
{
public function __invoke(Message $message): void;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Handlers;
use Siteworxpro\App\Attributes\Async\HandlesMessage;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesMessage(SayHelloMessage::class)]
class SayHelloHandler implements HandlerInterface
{
public function __invoke(Message | SayHelloMessage $message): void
{
$name = $message->getPayload()['name'] ?? 'Guest';
Logger::info(sprintf("Hello, %s!", $name));
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Async\Queues\DefaultQueue;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Helpers\Ulid;
abstract class Message implements \Serializable
{
protected string $id = '';
protected string $uniqueId;
protected array $payload;
protected int $timestamp;
protected string $queue = '';
protected const string DEFAULT_QUEUE = DefaultQueue::class;
abstract public static function dispatch(...$args): void;
abstract public static function dispatchLater(int $delay, ...$args): void;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Messages;
use Siteworxpro\App\Services\Facades\Broker;
class SayHelloMessage extends Message
{
public static function dispatch(...$args): void
{
$name = $args[0] ?? 'World';
$message = new self($name);
Broker::publish(
$message->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,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Queues;
readonly class DefaultQueue extends Queue
{
public function queueName(): string
{
return 'default';
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Async\Queues;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Services\Facades\Broker;
readonly abstract class Queue
{
abstract public function queueName(): string;
public function push(Message $message): void
{
Broker::publish($this, $message);
}
public function later(int $delay, Message $message): void
{
Broker::publish($this, $message, $delay);
}
public function pop(): Message | null
{
return Broker::consume($this);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Async;
use Attribute;
/**
* Attribute to mark a class as a handler for a specific message class in an async workflow.
*
* Repeatable: attach multiple times to handle multiple message classes.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
readonly class HandlesMessage
{
/**
* Create a new HandlesMessage attribute.
*
* @param class-string $messageClass Fully-qualified class name of the message handled.
*/
public function __construct(
public string $messageClass,
) {
}
/**
* Get the fully-qualified message class this handler processes.
*
* @return class-string
*/
public function getMessageClass(): string
{
return $this->messageClass;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\CommandBus;
use Attribute;
/**
* Class HandlesCommand
* @package Siteworxpro\App\Attributes\CommandBus
*/
#[Attribute(Attribute::TARGET_CLASS)]
readonly class HandlesCommand
{
/**
* @param class-string $commandClass
*/
public function __construct(public string $commandClass)
{
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Events;
use Attribute;
/**
* Attribute to mark a class as an event listener for a specific event class.
*
* Apply this attribute to classes that subscribe to domain or application events.
* Repeatable: can be attached multiple times to the same class to listen for multiple events.
*
* Targets: class only.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
readonly class ListensFor
{
/**
* Initialize the ListensFor attribute.
*
* @param class-string $eventClass Fully-qualified class name of the event to listen for.
*/
public function __construct(public string $eventClass)
{
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
use Siteworxpro\App\Services\Facades\Config;
/**
* Attribute to guard classes or methods with JWT claim requirements.
*
* Apply this attribute to a class or method to declare the expected JWT issuer and/or audience.
* If either the issuer or audience is an empty string, the value will be resolved from configuration:
* - `jwt.issuer`
* - `jwt.audience`
*
* Targets: class or method.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class Jwt
{
/**
* Initialize the Jwt attribute with optional overrides for expected JWT claims.
*
* @param string $issuer Optional expected JWT issuer (`iss`). Empty string uses `Config::get('jwt.issuer')`.
* @param string $audience Optional expected JWT audience (`aud`). Empty string uses `Config::get('jwt.audience')`.
*/
public function __construct(
private string $issuer = '',
private string $audience = '',
) {
}
/**
* Get the expected audience for validation.
*
* Returns the constructor-provided audience when non-empty; otherwise falls back to `jwt.audience` config.
*
* @return string The audience value to enforce.
*/
public function getAudience(): string
{
if ($this->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;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
readonly class RequireAllScopes
{
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Attributes\Guards;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class Scope
{
/**
* @param array<int, string> $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;
}
}

46
src/Cli/App.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli;
use Ahc\Cli\Application;
use Siteworxpro\App\Cli\Commands\DemoCommand;
use Siteworxpro\App\Cli\Commands\Queue\Start;
use Siteworxpro\App\Cli\Commands\Queue\TestJob;
use Siteworxpro\App\Helpers\Version;
use Siteworxpro\App\Kernel;
use Siteworxpro\App\Services\Facades\Config;
class App
{
private Application $app;
/**
* @throws \ReflectionException
*/
public function __construct()
{
Kernel::boot();
$this->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(
<<<EOF
▄▄▄▄▄ ▄ ▄ ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▀▀█ ▄
█ ▀█ █ █ █ ▀█ █ ▄▄▄ ▄▄▄▄▄ ▄▄▄▄ █ ▄▄▄ ▄▄█▄▄ ▄▄▄
█▄▄▄█▀ █▄▄▄▄█ █▄▄▄█▀ █ █▀ █ █ █ █ █▀ ▀█ █ ▀ █ █ █▀ █
█ █ █ █ ▀▀▀ █ █▀▀▀▀ █ █ █ █ █ █ ▄▀▀▀█ █ █▀▀▀▀
█ █ █ █ █ ▀█▄▄▀ █ █ █ ██▄█▀ ▀▄▄ ▀▄▄▀█ ▀▄▄ ▀█▄▄▀
EOF
);
return $this->app->handle($_SERVER['argv']);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
interface CommandInterface
{
/**
* Execute the command.
*
* @return int
*/
public function execute(): int;
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
class DemoCommand extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('api:demo', 'A demo command to showcase the CLI functionality.');
$this->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;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Consumer;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
class Start extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:start', 'Start the queue consumer to process messages.');
$this->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;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Cli\Commands\Queue;
use Ahc\Cli\Input\Command;
use Siteworxpro\App\Async\Messages\SayHelloMessage;
use Siteworxpro\App\Cli\Commands\CommandInterface;
/**
* Class TestJob
*
* A CLI command to schedule a demo job that dispatches a SayHelloMessage.
*/
class TestJob extends Command implements CommandInterface
{
public function __construct()
{
parent::__construct('queue:demo', 'Schedule a demo job.');
}
/**
* Execute the command to dispatch a SayHelloMessage.
*
* @return int Exit code
*/
public function execute(): int
{
SayHelloMessage::dispatch('World from TestJob Command!');
return 0;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus;
use League\Tactician\Exception\CanNotInvokeHandlerException;
use League\Tactician\Handler\Locator\HandlerLocator;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
class AttributeLocator implements HandlerLocator
{
private const string HANDLER_NAMESPACE = 'Siteworxpro\\App\\CommandBus\\Handlers\\';
private array $handlers;
public function __construct()
{
$directory = __DIR__ . '/Handlers';
$files = scandir($directory);
foreach ($files as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
$className = pathinfo($file, PATHINFO_FILENAME);
$fullClassName = self::HANDLER_NAMESPACE . $className;
if (class_exists($fullClassName)) {
$reflectionClass = new \ReflectionClass($fullClassName);
$attributes = $reflectionClass->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);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly abstract class Command
{
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Commands;
readonly class ExampleCommand extends Command
{
public function __construct(
private string $name
) {
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
abstract class CommandHandler implements CommandHandlerInterface
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\CommandBus\Commands\Command;
interface CommandHandlerInterface
{
public function __invoke(Command $command): mixed;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\CommandBus\Handlers;
use Siteworxpro\App\Attributes\CommandBus\HandlesCommand;
use Siteworxpro\App\CommandBus\Commands\Command;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\Logger;
#[HandlesCommand(ExampleCommand::class)]
class ExampleHandler extends CommandHandler
{
/**
* @param Command|ExampleCommand $command
* @return string
*/
public function __invoke(Command|ExampleCommand $command): string
{
if (!method_exists($command, 'getName')) {
throw new \TypeError('Invalid command type provided to ExampleHandler.');
}
$name = $command->getName();
Logger::info('Handling ExampleCommand for name: ' . $name);
return 'Hello, ' . $name . '!';
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use League\Route\Http\Exception\NotFoundException;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Helpers\Version;
/**
* Class Controller
*
* An abstract base controller providing default implementations for HTTP methods.
*
* @package Siteworxpro\App\Controllers
*/
#[OA\Info(
version: Version::VERSION,
description: "This is a template API built using Siteworxpro framework.",
title: "Siteworxpro Template API",
contact: new OA\Contact(
name: "Siteworxpro",
url: "https://www.siteworxpro.com",
email: "support@siteworxpro.com"
),
license: new OA\License('MIT', 'https://opensource.org/licenses/MIT')
)]
#[OA\Server(url: "https://localhost", description: "Local Server")]
abstract class Controller implements ControllerInterface
{
/**
* @param ServerRequest $request
* @return ResponseInterface
* @throws NotFoundException
*/
public function get(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function post(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function put(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function delete(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
/**
* @throws NotFoundException
*/
public function patch(ServerRequest $request): ResponseInterface
{
throw new NotFoundException("not found");
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
/**
* Interface ControllerInterface
*
* Defines the contract for handling HTTP requests in a controller.
*/
interface ControllerInterface
{
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function get(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function post(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function put(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function delete(ServerRequest $request): ResponseInterface;
/**
* Handle the request and return a response.
*
* @param ServerRequest $request The request data.
* @return ResponseInterface The response data.
*/
public function patch(ServerRequest $request): ResponseInterface;
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Illuminate\Database\PostgresConnection;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Http\Responses\ServerErrorResponse;
use Siteworxpro\App\Models\Model;
use Siteworxpro\App\Services\Facades\Logger;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
/**
* Class HealthcheckController
*
* Handles health check requests to verify database and cache connectivity.
*
* @package Siteworxpro\App\Controllers
*/
class HealthcheckController extends Controller
{
/**
* Handles the GET request for health check.
*
* @throws \JsonException
*/
#[OA\Get(path: '/healthz', tags: ['Healthcheck'])]
#[OA\Response(response: '200', description: 'Healthcheck OK')]
#[OA\Response(response: '503', description: 'Healthcheck Failed')]
public function get(ServerRequest $request): ResponseInterface
{
try {
/** @var PostgresConnection $conn */
$conn = Model::getConnectionResolver()->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')
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\ServerRequest;
use Psr\Http\Message\ResponseInterface;
use Siteworxpro\App\Attributes\Guards;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Docs\TokenSecurity;
use Siteworxpro\App\Docs\UnauthorizedResponse;
use Siteworxpro\App\Http\JsonResponseFactory;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Http\Responses\GenericResponse;
use Siteworxpro\App\Services\Facades\CommandBus;
/**
* Class IndexController
*
* This class handles the index route of the application.
*/
class IndexController extends Controller
{
/**
* Handles the GET request for the index route.
*
* @throws \JsonException
*/
#[Guards\Jwt]
#[Guards\Scope(['get.index', 'status.check'])]
#[Guards\RequireAllScopes]
#[OA\Get(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 get(ServerRequest $request): ResponseInterface
{
$command = new ExampleCommand($request->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'));
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Controllers;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;
use OpenApi\Generator;
use Psr\Http\Message\ResponseInterface;
class OpenApiController extends Controller
{
/**
* Handles the GET request to generate and return the OpenAPI specification.
*
* @param ServerRequest $request
* @return ResponseInterface
*/
public function get(ServerRequest $request): ResponseInterface
{
$openapi = new Generator()->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');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
class TokenSecurity extends OA\SecurityScheme
{
public function __construct()
{
parent::__construct(
securityScheme: 'bearerAuth',
type: 'http',
description: 'JWT based authentication using Bearer tokens.',
bearerFormat: 'JWT',
scheme: 'bearer'
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Siteworxpro\App\Docs;
use OpenApi\Attributes as OA;
#[\Attribute]
class UnauthorizedResponse extends OA\Response
{
public function __construct()
{
parent::__construct(
response: '401',
description: 'Unauthorized - Authentication is required and has failed or has not yet been provided.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Unauthorized'),
]
)
)
);
}
}

246
src/Events/Dispatcher.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events;
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use Siteworxpro\App\Attributes\Events\ListensFor;
use function React\Async\await;
use function React\Async\coroutine;
/**
* Class Dispatcher
*
* A custom event dispatcher that automatically registers event listeners
* based on the ListensFor attribute.
*
* @package Siteworxpro\App\Events
*/
class Dispatcher implements DispatcherContract, Arrayable
{
/**
* @var array $listeners Registered event listeners
*/
private array $listeners = [];
/**
* @var Collection $pushed Pushed events collection
*/
private Collection $pushed;
private array $subscribers = [];
/**
* @var string LISTENERS_NAMESPACE The namespace where listeners are located
*/
private const string LISTENERS_NAMESPACE = 'Siteworxpro\\App\\Events\\Listeners\\';
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners\Database;
use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Database\Events\ConnectionEvent;
use Siteworxpro\App\Attributes\Events\ListensFor;
use Siteworxpro\App\Events\Listeners\Listener;
use Siteworxpro\App\Services\Facades\Logger;
/**
* Class Connected
* @package Siteworxpro\App\Events\Listeners\Database
*/
#[ListensFor(ConnectionEstablished::class)]
class Connected extends Listener
{
/**
* @param mixed $event
* @param array $payload
* @return null
*/
public function __invoke(mixed $event, array $payload = []): null
{
if (!($event instanceof ConnectionEvent)) {
throw new \TypeError("Invalid event type passed to listener " . static::class);
}
Logger::info("Database connection event", [get_class($event), $event->connectionName]);
return null;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
/**
* Class Listener
*
* @package Siteworxpro\App\Events\Listeners
*/
abstract class Listener implements ListenerInterface
{
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Listeners;
/**
* Interface ListenerInterface
* @package Siteworxpro\App\Events\Listeners
*/
interface ListenerInterface
{
/**
* @param mixed $event
* @param array $payload
* @return mixed
*/
public function __invoke(mixed $event, array $payload = []): mixed;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
use Illuminate\Contracts\Support\Arrayable;
abstract class Subscriber implements SubscriberInterface, Arrayable
{
public function toArray(): array
{
return get_object_vars($this);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Events\Subscribers;
interface SubscriberInterface
{
public function handle(string $eventName, mixed $payload): mixed;
}

54
src/Grpc.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App;
use GRPC\Greeter\GreeterInterface;
use Siteworxpro\App\GrpcHandlers\GreeterHandler;
use Siteworxpro\App\Services\Facades\Config;
use Spiral\RoadRunner\GRPC\Invoker;
use Spiral\RoadRunner\GRPC\Server;
use Spiral\RoadRunner\Worker;
/**
* Class Grpc
*
* starts a gRPC server using RoadRunner
*
* @package Siteworxpro\App
*/
class Grpc
{
private const array SERVICES = [
GreeterInterface::class => 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;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\GrpcHandlers;
use GRPC\Greeter\GreeterInterface;
use GRPC\Greeter\HelloReply;
use GRPC\Greeter\HelloRequest;
use Siteworxpro\App\CommandBus\Commands\ExampleCommand;
use Siteworxpro\App\Services\Facades\CommandBus;
use Spiral\RoadRunner\GRPC;
class GreeterHandler implements GreeterInterface
{
public function SayHello(GRPC\ContextInterface $ctx, HelloRequest $in): HelloReply // phpcs:ignore
{
$command = new ExampleCommand($in->getName());
$reply = new HelloReply();
$reply->setMessage(CommandBus::handle($command));
return $reply;
}
}

30
src/Helpers/Env.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
/**
* Class Env
* @package Siteworxpro\App\Helpers
*/
abstract class Env
{
/**
* @param string $key
* @param null $default
* @param string $castTo
* @return float|bool|int|string
*/
public static function get(string $key, $default = null, string $castTo = 'string'): float | bool | int | string
{
$env = getenv($key) !== false ? getenv($key) : $default;
return match ($castTo) {
'bool', 'boolean' => (bool) $env,
'int', 'integer' => (int) $env,
'float' => (float) $env,
default => (string) $env,
};
}
}

22
src/Helpers/Ulid.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
/**
* Class Ulid
* @package Siteworxpro\App\Helpers
*/
class Ulid
{
/**
* Generate a ULID string
*
* @return string
*/
public static function generate(): string
{
return \Ulid\Ulid::generate()->getRandomness();
}
}

10
src/Helpers/Version.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Helpers;
class Version
{
public const string VERSION = 'dev-master';
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http;
use Illuminate\Contracts\Support\Arrayable;
use Nyholm\Psr7\Response;
use Siteworxpro\HttpStatus\CodesEnum;
/**
* Class JsonResponseFactory
*
* A factory class for creating JSON responses.
*/
class JsonResponseFactory
{
/**
* Create a JSON response with the given data and status code.
*
* @param array|Arrayable $data The data to include in the response.
* @param CodesEnum $statusCode The HTTP status code for the response.
* @return Response The JSON response.
* @throws \JsonException
*/
public static function createJsonResponse(
array|Arrayable $data,
CodesEnum $statusCode = CodesEnum::OK
): Response {
if ($data instanceof Arrayable) {
$data = $data->toArray();
}
return new Response(
status: $statusCode->value,
headers: [
'Content-Type' => 'application/json',
],
body: json_encode($data, JSON_THROW_ON_ERROR)
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Services\Facades\Config;
/**
* Class CorsMiddleware
*
* Middleware to handle CORS (Cross-Origin Resource Sharing) requests.
* It checks the origin of the request and sets appropriate CORS headers
* in the response.
*/
class CorsMiddleware implements MiddlewareInterface
{
/**
* Process the incoming request and add CORS headers to the response.
*
* @param ServerRequestInterface $request The incoming request.
* @param RequestHandlerInterface $handler The request handler.
* @return ResponseInterface The response with CORS headers.
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$origin = $request->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);
}
}

View File

@@ -0,0 +1,322 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use Carbon\Carbon;
use Carbon\WrapperClock;
use GuzzleHttp\Exception\GuzzleException;
use Lcobucci\JWT\JwtFacade;
use Lcobucci\JWT\Signer\Hmac\Sha256 as Hmac256;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use Lcobucci\JWT\Validation\Constraint\StrictValidAt;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\Jwt;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Guzzle;
use Siteworxpro\App\Services\Facades\Redis;
use Siteworxpro\HttpStatus\CodesEnum;
/**
* JWT authorization middleware.
*
* Applies JWT validation to controller actions annotated with `Jwt` attribute.
* Flow:
* - Resolve the targeted controller and method for the current route.
* - If the method has `Jwt`, read the `Authorization` header and parse the Bearer token.
* - Validate signature, time constraints, issuer\(\) and audience\(\) based on attribute and config.
* - On success, attach all token claims to the request as attributes.
* - On failure, return a 401 JSON response with validation errors.
*
* Configuration:
* - `jwt.signing_key`: key material or `file://` path to key.
* - `jwt.strict_validation`: bool toggling strict vs loose time validation.
*/
class JwtMiddleware extends Middleware
{
/**
* Process the incoming request.
*
* If the matched controller method is annotated with `Jwt`, validates the token and
* augments the request with claims on success. Otherwise, just delegates to the next handler.
*
* @param ServerRequestInterface $request PSR-7 request instance.
* @param RequestHandlerInterface|Dispatcher $handler Next middleware or route dispatcher.
*
* @return ResponseInterface Response produced by the next handler or a 401 JSON response.
*
* @throws \JsonException On JSON error response encoding issues.
* @throws \Exception On unexpected reflection or JWT parsing issues.
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Resolve the callable \[Controller, method] for the current route.
$callable = $this->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;
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use League\Route\Dispatcher;
use League\Route\Route;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
/**
* Base middleware helper for extracting route callables.
*
* This abstract middleware provides a utility method to inspect a League\Route
* dispatcher and obtain the underlying route callable as a [class, method] tuple.
*
* @package Siteworxpro\App\Http\Middleware
*/
abstract class Middleware implements MiddlewareInterface
{
/**
* Extract the route callable [class, method] from a League\Route dispatcher.
*
* When the provided handler is a League\Route\Dispatcher, this inspects its
* middleware stack, looks at the last segment (the resolved Route), and
* attempts to normalize its callable into a [class, method] pair.
*
* Supported callable forms:
* - array callable: [object|class-string, method-string]
* - string callable: "ClassName::methodName"
*
* Returns null when the handler is not a Dispatcher, the stack is empty,
* or the callable cannot be parsed.
*
* @param RequestHandlerInterface|Dispatcher $handler The downstream handler or dispatcher.
*
* @return array{0: class-string|object|null, 1: string|null}|null Tuple of [class|object, method] or null.
*/
protected function extractRouteCallable(
RequestHandlerInterface|Dispatcher $handler
): array|null {
// Only proceed if this is a League\Route dispatcher.
if (!$handler instanceof Dispatcher) {
return null;
}
/** @var Route | null $lastSegment */
// Retrieve the last middleware in the stack, which should be the Route.
$lastSegment = array_last($handler->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];
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Middleware;
use League\Route\Dispatcher;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Siteworxpro\App\Attributes\Guards\RequireAllScopes;
use Siteworxpro\App\Attributes\Guards\Scope;
use Siteworxpro\App\Controllers\Controller;
use Siteworxpro\App\Http\JsonResponseFactory;
use Siteworxpro\HttpStatus\CodesEnum;
/**
* Middleware that enforces scope-based access control on controller actions.
*
* It inspects PHP 8 attributes of type \`Scope\` applied to the resolved controller method,
* compares the required scopes with the user scopes provided on the request attribute \`scopes\`,
* and returns a 403 JSON response when any required scope is missing.
*
* If the route callable cannot be resolved, or no scope is required, the request is passed through.
*
* @see Scope
*/
class ScopeMiddleware extends Middleware
{
/**
* Resolve the route callable, read any \`Scope\` attributes, and enforce required scopes.
*
* Expected user scopes are provided on the request under the attribute name \`scopes\`
* as an array of strings.
*
* @param ServerRequestInterface $request Incoming PSR-7 request (expects \`scopes\` attribute).
* @param RequestHandlerInterface|Dispatcher $handler Next handler or League\Route dispatcher.
*
* @return ResponseInterface A 403 JSON response when scopes are insufficient; otherwise the handler response.
*
* @throws \JsonException If encoding the JSON error response fails.
* @throws \ReflectionException If reflection on the controller or method fails.
*/
public function process(
ServerRequestInterface $request,
RequestHandlerInterface|Dispatcher $handler
): ResponseInterface {
// Attempt to resolve the route's callable [Controller instance, method name].
$callable = $this->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);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'GenericResponse',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Operation completed successfully.'),
]
)]
readonly class GenericResponse implements Arrayable
{
public function __construct(
private string $message = '',
) {
}
public function toArray(): array
{
return [
'message' => $this->message,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'NotFoundResponse',
properties: [
new OA\Property(
property: 'message',
type: 'string',
example: 'The requested resource /api/resource was not found.'
),
new OA\Property(
property: 'context',
description: 'Additional context about the not found error.',
type: 'object',
example: '{}'
),
]
)]
readonly class NotFoundResponse implements Arrayable
{
public function __construct(private string $uri, private array $context = [])
{
}
public function toArray(): array
{
return [
'message' => 'The requested resource ' . $this->uri . ' was not found.',
'context' => $this->context,
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Siteworxpro\App\Http\Responses;
use Illuminate\Contracts\Support\Arrayable;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\HttpStatus\CodesEnum;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'ServerErrorResponse',
properties: array(
new OA\Property(property: 'message', type: 'string', example: 'An internal server error occurred.'),
new OA\Property(property: 'code', type: 'integer', example: 500),
new OA\Property(
property: 'file',
type: 'string',
example: '/var/www/html/app/Http/Controllers/ExampleController.php'
),
new OA\Property(property: 'line', type: 'integer', example: 42),
new OA\Property(
property: 'trace',
type: 'array',
items: new OA\Items(type: 'string'),
)
)
)]
readonly class ServerErrorResponse implements Arrayable
{
public function __construct(private \Throwable $e, private array $context = [])
{
}
public function toArray(): array
{
if (Config::get('app.dev_mode')) {
return [
'code' => $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.',
];
}
}

97
src/Kernel.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
namespace Siteworxpro\App;
use Illuminate\Container\Container;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Support\ServiceProvider;
use Siteworx\Config\Config as SWConfig;
use Siteworxpro\App\Services\Facade;
use Siteworxpro\App\Services\Facades\Config;
use Siteworxpro\App\Services\Facades\Dispatcher;
use Siteworxpro\App\Services\ServiceProviders\BrokerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\CommandBusProvider;
use Siteworxpro\App\Services\ServiceProviders\DispatcherServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\LoggerServiceProvider;
use Siteworxpro\App\Services\ServiceProviders\RedisServiceProvider;
/**
* Class Kernel
*
* The Kernel class is responsible for bootstrapping the application by
* initializing service providers and setting up the database connection.
*
* @package Siteworxpro\App
*/
class Kernel
{
/**
* List of service providers to be registered during bootstrapping.
*
* @var array
*/
private static array $serviceProviders = [
LoggerServiceProvider::class,
RedisServiceProvider::class,
DispatcherServiceProvider::class,
BrokerServiceProvider::class,
CommandBusProvider::class,
];
/**
* Bootstraps the server by initializing the PSR-7 worker and router.
*
* This method sets up the PSR-7 worker and router instances, and registers
* the routes for the server. It should be called in the constructor of
* subclasses to ensure proper initialization.
*
* @return void
* @throws \ReflectionException
*/
public static function boot(): void
{
$container = new Container();
Facade::setFacadeContainer($container);
// Bind the container to the Config facade first so that it can be used by service providers
$container->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();
}
}

236
src/Log/Logger.php Normal file
View File

@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Log;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use RoadRunner\Logger\Logger as RRLogger;
use Siteworxpro\App\Services\Facades\RoadRunnerLogger;
/**
* Logger implementation that conforms to PSR-3 (`Psr\Log\LoggerInterface`).
*
* Behavior:
* - If environment indicates RoadRunner RPC (`$_SERVER['RR_RPC']`), logs are forwarded
* to a RoadRunner RPC logger (`RoadRunner\Logger\Logger`) created via Goridge RPC.
* - Otherwise, logs are written to `php://stdout` using Monolog with a JSON formatter.
* - Messages below the configured threshold are ignored (level filtering).
*
* Supported PSR-3 levels are mapped to an internal numeric ordering in `$levels`.
* When using the RPC logger, levels are translated to the respective RPC methods
* (debug, info, warning, error). When using Monolog, the numeric mapping is used
* as the numeric level passed to Monolog's `log` method.
*/
class Logger implements LoggerInterface
{
/**
* RoadRunner RPC logger instance when running under RoadRunner.
*
* @var RRLogger | LoggerInterface | null
*/
private RRLogger | LoggerInterface | null $rpcLogger = null;
/**
* Monolog logger used as a fallback to write JSON-formatted logs to stdout.
*
* @var \Monolog\Logger
*/
private \Monolog\Logger $monologLogger;
/**
* Numeric ordering for PSR-3 log levels.
*
* Lower numbers represent higher severity. This mapping is used for filtering
* messages according to the configured minimum level and for Monolog numeric level.
*
* @var array<string,int>
*/
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);
}
}

17
src/Models/Model.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Illuminate\Database\Eloquent\Model as ORM;
/**
* Class Model
*
* @package Siteworxpro\App\Models
*/
abstract class Model extends ORM
{
protected $dateFormat = 'Y-m-d H:i:s';
}

77
src/Models/User.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Models;
use Carbon\Carbon;
use OpenApi\Attributes as OA;
use Siteworxpro\App\Helpers\Ulid;
/**
* Class User
*
* @property-read string $id
* @property string $first_name
* @property string $last_name
* @property string $email
* @property string $password
* @property Carbon $created_at
*
* @property-read string $full_name
* @property-read string $formatted_email
*/
#[OA\Schema(
schema: "User",
properties: [
new OA\Property(
property: "id",
description: "Unique identifier for the user",
type: "string",
format: "ulid",
readOnly: true,
example: '01KBD5WPZKYD77BYM2QD9NKG99'
),
new OA\Property(property: "first_name", type: "string"),
new OA\Property(property: "last_name", type: "string"),
new OA\Property(property: "email", type: "string", format: "email"),
new OA\Property(property: "created_at", type: "string", format: "date-time"),
]
)]
class User extends Model
{
protected $casts = [
'created_at' => '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)
);
}
}

278
src/Services/Facade.php Normal file
View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\HigherOrderTapProxy;
use Illuminate\Support\Testing\Fakes\Fake;
use Mockery;
use Mockery\Expectation;
use Mockery\ExpectationInterface;
use Mockery\LegacyMockInterface;
use Mockery\MockInterface;
class Facade
{
/**
* The application instance being facaded.
*
* @var Container | null
*/
protected static ?Container $container = null;
/**
* The resolved object instances.
*
* @var array
*/
protected static array $resolvedInstance;
/**
* Indicates if the resolved instance should be cached.
*
* @var bool
*/
protected static bool $cached = true;
/**
* Run a Closure when the facade has been resolved.
*
* @param \Closure $callback
* @return void
*/
public static function resolved(\Closure $callback): void
{
$accessor = static::getFacadeAccessor();
if (static::$container->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);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Siteworxpro\App\Services\Facades;
use Siteworxpro\App\Async\Messages\Message;
use Siteworxpro\App\Async\Queues\Queue;
use Siteworxpro\App\Services\Facade;
/**
* Broker Facade
*
* @method static void publish(Queue $queue, Message $message, int $delay = 0)
* @method static void publishLater(Queue $queue, Message $message, int $delay)
* @method static Message|null consume(Queue $queue)
*/
class Broker extends Facade
{
/**
* Get the registered name of the component.
*
* @return string The name of the component.
*/
protected static function getFacadeAccessor(): string
{
return \Siteworxpro\App\Async\Brokers\Broker::class;
}
}

Some files were not shown because too many files have changed in this diff Show More