diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..2a1d8cc
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,79 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [ main, develop ]
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php:
+ - '8.3'
+ - '8.4'
+ coverage: ['none']
+ symfony-versions:
+ - '6.4.*'
+ - '7.0.*'
+ include:
+ - description: 'Log Code Coverage'
+ php: '8.3'
+ symfony-versions: '^7.0'
+ doctrine-orm-versions: '^3.0'
+ coverage: xdebug
+
+ name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - uses: actions/cache@v4
+ with:
+ path: ~/.composer/cache/files
+ key: ${{ matrix.php }}-${{ matrix.symfony-versions }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: xdebug
+
+ - name: Add PHPUnit matcher
+ run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
+
+ - name: Set composer cache directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache composer
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer-${{ hashFiles('composer.json') }}
+ restore-keys: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.symfony-versions }}-composer
+
+ - name: Update Symfony version
+ if: matrix.symfony-versions != ''
+ run: |
+ composer require symfony/framework-bundle:${{ matrix.symfony-versions }} --no-update --no-scripts
+ composer require --dev symfony/yaml:${{ matrix.symfony-versions }} --no-update --no-scripts
+
+ - name: Install dependencies
+ run: composer install
+
+ - name: Run PHPUnit tests
+ run: vendor/bin/phpunit
+ if: matrix.coverage == 'none'
+
+ - name: PHPUnit tests and Log Code coverage
+ run: vendor/bin/phpunit --coverage-clover=coverage.xml
+ if: matrix.coverage == 'xdebug'
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.coverage == 'xdebug'
+ uses: codecov/codecov-action@v4.0.1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml
new file mode 100644
index 0000000..248d5d0
--- /dev/null
+++ b/.github/workflows/security.yaml
@@ -0,0 +1,24 @@
+on:
+ pull_request:
+ push:
+ branches: [ main, develop ]
+
+jobs:
+ security-checker:
+ name: Security checker
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+
+ - name: Install dependencies
+ run: composer install --no-progress --no-interaction --prefer-dist
+
+ - name: Download local-php-security-checker
+ run: curl -s -L -o local-php-security-checker https://github.com/fabpot/local-php-security-checker/releases/download/v1.0.0/local-php-security-checker_1.0.0_linux_amd64
+
+ - name: Run local-php-security-checker
+ run: chmod +x local-php-security-checker && ./local-php-security-checker
diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml
new file mode 100644
index 0000000..8796ae1
--- /dev/null
+++ b/.github/workflows/static-analysis.yaml
@@ -0,0 +1,55 @@
+name: Code style and static analysis
+
+on:
+ pull_request:
+ push:
+ branches: [ main, develop ]
+
+jobs:
+ php-cs-fixer:
+ name: PHP-CS-Fixer
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+
+ - name: Install dependencies
+ run: composer install --no-progress --no-interaction --prefer-dist
+
+ - name: Run script
+ run: vendor/bin/phpcs
+
+ phpstan:
+ name: PHPStan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+
+ - name: Install dependencies
+ run: composer install --no-progress --no-interaction --prefer-dist
+
+ - name: Run script
+ run: vendor/bin/phpstan analyse
+
+ composer-validate:
+ name: Composer validate
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+
+ - name: Install dependencies
+ run: composer install --no-progress --no-interaction --prefer-dist
+
+ - name: Run script
+ run: composer composer-validate
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1473148
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.idea
+/vendor/
+/composer.lock
+/.phpcs-cache
+
+###> phpunit/phpunit ###
+/phpunit.xml
+.phpunit.result.cache
+###< phpunit/phpunit ###
diff --git a/README.md b/README.md
index 4e65f43..dbf53bb 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,89 @@
-# schema-context-bundle
-Schema context bundle
+# Schema Context Bundle
+
+The **SchemaContextBundle** provides a lightweight way to manage dynamic schema context across your Symfony application, especially useful for multi-tenant setups. It allows schema resolution based on request headers and propagates schema information through Symfony Messenger.
+
+---
+
+## Features
+
+- Extracts tenant schema from request headers.
+- Stores schema context in a global `SchemaResolver`.
+- Injects schema info into Messenger messages via a middleware.
+- Rehydrates schema on message consumption via a middleware.
+
+---
+
+## Installation
+
+```bash
+composer require macpaw/schema-context-bundle
+```
+
+If you are not using Symfony Flex, register the bundle manually:
+
+```php
+// config/bundles.php
+return [
+ Macpaw\SchemaContextBundle\SchemaContextBundle::class => ['all' => true],
+];
+```
+## Configuration
+### 1. Bundle Configuration
+Add this config to `config/packages/schema_context.yaml`:
+
+```yaml
+schema_context:
+ app_name: '%env(APP_NAME)%' # Application name
+ header_name: 'X-Tenant' # Request header to extract schema name
+ default_schema: 'public' # Default schema to fallback to
+ allowed_app_names: ['develop', 'staging', 'test'] # App names where schema context is allowed to change
+```
+### 2. Set Environment Parameters
+If you're using .env, define the app name:
+
+```env
+APP_NAME=develop
+```
+
+## Usage
+
+```php
+use Macpaw\SchemaContextBundle\Service\SchemaResolver;
+
+public function index(SchemaResolver $schemaResolver)
+{
+ $schema = $schemaResolver->getSchema();
+ // Use schema in logic
+}
+```
+
+## Messenger Integration
+The bundle provides a middleware that automatically:
+
+* Adds a SchemaStamp to dispatched messages
+
+* Restores the schema context on message handling
+
+Enable the middleware in your `messenger.yaml`:
+
+```yaml
+framework:
+ messenger:
+ buses:
+ messenger.bus.default:
+ middleware:
+ - Macpaw\SchemaContextBundle\Messenger\Middleware\SchemaMiddleware
+```
+
+## Testing
+To run tests:
+```bash
+vendor/bin/phpunit
+```
+
+## Contributing
+Feel free to open issues and submit pull requests.
+
+## License
+This bundle is released under the MIT license.
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..0b6878f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,23 @@
+# Security Policy
+
+## Reporting Security Issues
+If you believe you have found a security vulnerability in any MacPaw-owned repository, please report it to us through coordinated disclosure.
+
+Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
+
+Instead, please send an email to security[@]macpaw.com.
+
+Please include as much of the information listed below as you can to help us better understand and resolve the issue:
+
+- The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting)
+- Full paths of source file(s) related to the manifestation of the issue
+- The location of the affected source code (tag/branch/commit or direct URL)
+- Any special configuration required to reproduce the issue
+- Step-by-step instructions to reproduce the issue
+- Proof-of-concept or exploit code (if possible)
+- Impact of the issue, including how an attacker might exploit the issue
+
+This information will help us triage your report more quickly.
+
+## Policy
+See MacPaw's [Vulnerability Disclosure Policy](https://macpaw.com/vulnerability-disclosure-policy)
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..37a7d71
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,49 @@
+{
+ "name": "macpaw/schema-context-bundle",
+ "description": "A Symfony bundle to provide schema context",
+ "type": "symfony-bundle",
+ "autoload": {
+ "psr-4": {
+ "Macpaw\\SchemaContextBundle\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Macpaw\\SchemaContextBundle\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "php": ">=8.3",
+ "symfony/messenger": "^6.4 || ^7.0",
+ "symfony/http-kernel": "^6.4 || ^7.0",
+ "symfony/dependency-injection": "^6.4 || ^7.0",
+ "symfony/config": "^6.4 || ^7.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10.0",
+ "squizlabs/php_codesniffer": "3.7.*"
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
+ "scripts": {
+ "composer-validate": [
+ "composer validate"
+ ],
+ "cs": [
+ "vendor/bin/phpcs"
+ ],
+ "cs-fix": [
+ "vendor/bin/phpcbf"
+ ],
+ "phpstan": [
+ "vendor/bin/phpstan analyse"
+ ],
+ "phpunit": [
+ "vendor/bin/phpunit"
+ ]
+ }
+}
diff --git a/config/services.yaml b/config/services.yaml
new file mode 100644
index 0000000..2acb1ef
--- /dev/null
+++ b/config/services.yaml
@@ -0,0 +1,23 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ Macpaw\SchemaContextBundle\Service\SchemaResolver:
+ public: true
+ shared: true
+
+ Macpaw\SchemaContextBundle\EventListener\SchemaRequestListener:
+ arguments:
+ $schemaResolver: '@Macpaw\SchemaContextBundle\Service\SchemaResolver'
+ $schemaRequestHeader: '%schema_context.header_name%'
+ $defaultSchema: '%schema_context.default_schema%'
+ $appName: '%schema_context.app_name%'
+ $allowedAppNames: '%schema_context.allowed_app_names%'
+ tags:
+ - { name: kernel.event_subscriber }
+
+ Macpaw\SchemaContextBundle\Messenger\Middleware\SchemaMiddleware:
+ tags:
+ - { name: messenger.middleware }
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..d89bc03
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/
+ tests/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..f6ecc8a
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,10 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ ignoreErrors:
+ -
+ message: '~Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).~'
+ count: 1
+ path: ./src/DependencyInjection
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..6344d8d
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,23 @@
+
+
+
+
+ ./tests
+
+
+
+
+
+
+
+
+
+ ./src
+
+
+
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
new file mode 100644
index 0000000..f68fdc9
--- /dev/null
+++ b/src/DependencyInjection/Configuration.php
@@ -0,0 +1,29 @@
+getRootNode()
+ ->children()
+ ->scalarNode('header_name')->defaultValue('X-Schema')->end()
+ ->scalarNode('default_schema')->defaultValue('public')->end()
+ ->scalarNode('app_name')->isRequired()->cannotBeEmpty()->end()
+ ->arrayNode('allowed_app_names')
+ ->scalarPrototype()->end()
+ ->defaultValue([])
+ ->end()
+ ->end();
+
+ return $treeBuilder;
+ }
+}
diff --git a/src/DependencyInjection/SchemaContextExtension.php b/src/DependencyInjection/SchemaContextExtension.php
new file mode 100644
index 0000000..f2a578f
--- /dev/null
+++ b/src/DependencyInjection/SchemaContextExtension.php
@@ -0,0 +1,28 @@
+processConfiguration($configuration, $configs);
+
+ $container->setParameter('schema_context.header_name', $config['header_name']);
+ $container->setParameter('schema_context.default_schema', $config['default_schema']);
+ $container->setParameter('schema_context.app_name', $config['app_name']);
+ $container->setParameter('schema_context.allowed_app_names', $config['allowed_app_names']);
+
+ $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
+
+ $loader->load('services.yaml');
+ }
+}
diff --git a/src/EventListener/SchemaRequestListener.php b/src/EventListener/SchemaRequestListener.php
new file mode 100644
index 0000000..aa40ea5
--- /dev/null
+++ b/src/EventListener/SchemaRequestListener.php
@@ -0,0 +1,46 @@
+ 'onKernelRequest'];
+ }
+
+ public function onKernelRequest(RequestEvent $event): void
+ {
+ if (!$this->isAllowedAppName()) {
+ return;
+ }
+
+ $schema = $event->getRequest()->headers->get($this->schemaRequestHeader, $this->defaultSchema);
+
+ if ($schema !== null && $schema !== '') {
+ $this->schemaResolver->setSchema($schema);
+ }
+ }
+
+ private function isAllowedAppName(): bool
+ {
+ return in_array($this->appName, $this->allowedAppNames, true);
+ }
+}
diff --git a/src/Messenger/Middleware/SchemaMiddleware.php b/src/Messenger/Middleware/SchemaMiddleware.php
new file mode 100644
index 0000000..78fe890
--- /dev/null
+++ b/src/Messenger/Middleware/SchemaMiddleware.php
@@ -0,0 +1,37 @@
+last(SchemaStamp::class);
+
+ if ($stamp instanceof SchemaStamp) {
+ $this->schemaResolver->setSchema($stamp->schema);
+
+ return $stack->next()->handle($envelope, $stack);
+ }
+
+ $schema = $this->schemaResolver->getSchema();
+
+ if ($schema !== null && $schema !== '') {
+ $envelope = $envelope->with(new SchemaStamp($schema));
+ }
+
+ return $stack->next()->handle($envelope, $stack);
+ }
+}
diff --git a/src/Messenger/Stamp/SchemaStamp.php b/src/Messenger/Stamp/SchemaStamp.php
new file mode 100644
index 0000000..ac5fd3c
--- /dev/null
+++ b/src/Messenger/Stamp/SchemaStamp.php
@@ -0,0 +1,14 @@
+schema = $schema;
+ }
+
+ public function getSchema(): ?string
+ {
+ return $this->schema;
+ }
+}
diff --git a/tests/EventListener/SchemaRequestListenerTest.php b/tests/EventListener/SchemaRequestListenerTest.php
new file mode 100644
index 0000000..baf2658
--- /dev/null
+++ b/tests/EventListener/SchemaRequestListenerTest.php
@@ -0,0 +1,55 @@
+ 'tenant1']);
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
+
+ $listener->onKernelRequest($event);
+
+ self::assertSame('tenant1', $resolver->getSchema());
+ }
+
+ public function testDefaultSchemaIsUsedIfHeaderMissing(): void
+ {
+ $resolver = new SchemaResolver();
+ $listener = new SchemaRequestListener(
+ $resolver,
+ 'X-Schema',
+ 'fallback',
+ 'test-app',
+ ['test-app'],
+ );
+
+ $request = new Request();
+ $kernel = $this->createMock(HttpKernelInterface::class);
+ $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST);
+
+ $listener->onKernelRequest($event);
+
+ self::assertSame('fallback', $resolver->getSchema());
+ }
+}
diff --git a/tests/Messenger/Middleware/SchemaMiddlewareTest.php b/tests/Messenger/Middleware/SchemaMiddlewareTest.php
new file mode 100644
index 0000000..0b6fcd4
--- /dev/null
+++ b/tests/Messenger/Middleware/SchemaMiddlewareTest.php
@@ -0,0 +1,67 @@
+with($stamp);
+ $stack = $this->createMock(StackInterface::class);
+ $nextMiddleware = new class implements MiddlewareInterface {
+ public function handle(Envelope $envelope, StackInterface $stack): Envelope
+ {
+ return $envelope;
+ }
+ };
+
+ $stack->expects($this->once())
+ ->method('next')
+ ->willReturn($nextMiddleware);
+
+ $middleware->handle($envelope, $stack);
+
+ $this->assertSame('tenant1', $resolver->getSchema());
+ }
+
+ public function testSchemaStampIsInjectedIfMissing(): void
+ {
+ $schema = 'tenant1';
+ $resolver = new SchemaResolver();
+ $resolver->setSchema($schema);
+ $middleware = new SchemaMiddleware($resolver);
+ $originalEnvelope = new Envelope(new \stdClass());
+ $stack = $this->createMock(StackInterface::class);
+
+ $stack->expects($this->once())
+ ->method('next')
+ ->willReturnCallback(function () {
+ return new class implements MiddlewareInterface {
+ public function handle(Envelope $envelope, StackInterface $stack): Envelope
+ {
+ return $envelope;
+ }
+ };
+ });
+
+ $resultEnvelope = $middleware->handle($originalEnvelope, $stack);
+
+ $stamp = $resultEnvelope->last(SchemaStamp::class);
+
+ $this->assertInstanceOf(SchemaStamp::class, $stamp);
+ $this->assertSame($schema, $stamp->schema);
+ }
+}