Tech Stack & Principles¶
Core Technology Stack¶
Backend¶
| Technology | Version | Purpose |
|---|---|---|
| PHP | 8.3+ | Programming language |
| Symfony | 8.x | Framework |
| Doctrine ORM | 3.x | Database abstraction |
| MySQL/MariaDB | 8.x / 10.x | Database |
Frontend¶
| Technology | Purpose |
|---|---|
| Twig | Server-side templating |
| Vanilla JS | Client-side interactivity |
| Vanilla CSS | Styling (no frameworks) |
Admin Interface¶
- Custom Twig templates - Full control, no admin bundles (EasyAdmin, Sonata)
- Built specifically for business requirements
Testing¶
| Tool | Purpose |
|---|---|
| Behat | BDD functional testing with Gherkin |
| PHPUnit | Unit testing for complex domain logic |
| PHPStan | Static analysis |
| PHP-CS-Fixer | Code style enforcement |
Architecture Principles¶
Clean Architecture¶
Each island follows clean architecture principles:
┌─────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers, CLI Commands, API) │
├─────────────────────────────────────────┤
│ Application Layer │
│ (Use Cases, Application Services) │
├─────────────────────────────────────────┤
│ Domain Layer │
│ (Entities, Value Objects, Interfaces) │
├─────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Repositories, External Services, ORM) │
└─────────────────────────────────────────┘
Rules: - Dependencies point inward (infrastructure depends on domain, not vice versa) - Domain layer has no external dependencies - Interfaces defined in domain, implemented in infrastructure
Adapter Pattern for External Services¶
All external integrations use the adapter pattern:
// Domain interface
interface VehicleDataProviderInterface
{
public function getVehicleBrands(): array;
public function getVehicleModels(string $brandId): array;
public function getVehicleSpecs(string $vehicleId): VehicleSpecs;
}
// Infrastructure implementations
class DriveRightAdapter implements VehicleDataProviderInterface { }
class WheelSizeAdapter implements VehicleDataProviderInterface { }
Benefits: - Swap providers without changing domain logic - Easy to test with mock implementations - Multiple providers can be combined
Multi-Tenancy Pattern¶
Shared database with tenant_id column:
// Entity
#[ORM\Entity]
class Customer
{
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
}
// Doctrine filter (applied globally)
class TenantFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
return sprintf('%s.tenant_id = %s', $targetTableAlias, $this->tenantId);
}
}
Tenant resolution:
- Subdomain matching: [tenantname].tyrecloud.be
- Stored in request attribute
- Doctrine filter automatically applied
Repository Pattern¶
All data access through repositories:
// Domain interface
interface CustomerRepositoryInterface
{
public function findById(CustomerId $id): ?Customer;
public function findByEmail(Email $email): ?Customer;
public function save(Customer $customer): void;
}
// Infrastructure implementation
class DoctrineCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function findById(CustomerId $id): ?Customer
{
return $this->em->find(Customer::class, $id);
}
}
Testing Strategy: BDD with Gherkin¶
Why BDD?¶
- Living documentation - Feature files document system behavior
- Business-readable - Stakeholders can understand and validate specs
- Test coverage - Features become executable tests
- AI-friendly - Clear specs help AI understand requirements
Feature File Structure¶
# features/checkout/cart.feature
Feature: Shopping Cart
As a customer
I want to add products to my cart
So that I can purchase them later
Background:
Given I am logged in as a B2B customer
And the following products exist:
| sku | name | price |
| TYRE-001 | Michelin PS4 | 150.00 |
| TYRE-002 | Continental SC | 140.00 |
Scenario: Add product to empty cart
Given my cart is empty
When I add product "TYRE-001" with quantity 4
Then my cart should contain 1 line item
And the line item should have quantity 4
And the cart total should be 600.00
Scenario: Increase quantity of existing product
Given my cart contains product "TYRE-001" with quantity 2
When I add product "TYRE-001" with quantity 2
Then my cart should contain 1 line item
And the line item should have quantity 4
Test Layers¶
| Layer | Tool | Purpose |
|---|---|---|
| Acceptance | Behat + Gherkin | Full feature validation |
| Integration | PHPUnit | Repository, service integration |
| Unit | PHPUnit | Domain logic, value objects |
What to Test at Each Layer¶
Acceptance (Behat): - User journeys (registration, checkout, order) - Business rules (pricing, discounts, visibility) - Multi-tenant behavior - API endpoints
Integration (PHPUnit): - Repository queries - External service adapters - Event handling
Unit (PHPUnit): - Value objects (Money, Email, VAT number) - Domain entities (state transitions, validation) - Complex calculations (pricing, matching)
Coding Standards¶
PSR Compliance¶
- PSR-1: Basic coding standard
- PSR-4: Autoloading
- PSR-12: Extended coding style
Symfony Best Practices¶
- Use attributes for routing, ORM mapping
- Constructor injection for dependencies
- Final classes by default
- Typed properties and return types
Naming Conventions¶
| Type | Convention | Example |
|---|---|---|
| Entity | Singular noun | Customer, Order |
| Repository | EntityRepository | CustomerRepository |
| Service | ActionNoun | OrderCreator, PriceCalculator |
| Command | VerbNounCommand | CreateOrderCommand |
| Handler | CommandHandler | CreateOrderHandler |
| Event | PastTenseEvent | OrderCreatedEvent |
| Interface | NounInterface | CustomerRepositoryInterface |
Directory Structure per Island¶
src/
├── Application/
│ ├── Command/ # CQRS commands
│ ├── Handler/ # Command handlers
│ ├── Query/ # CQRS queries
│ └── Service/ # Application services
├── Domain/
│ ├── Entity/ # Domain entities
│ ├── ValueObject/ # Value objects
│ ├── Repository/ # Repository interfaces
│ ├── Service/ # Domain services
│ └── Event/ # Domain events
├── Infrastructure/
│ ├── Doctrine/ # Doctrine repositories, types
│ ├── Http/ # External API clients
│ └── Messaging/ # Queue handlers
└── Presentation/
├── Controller/ # HTTP controllers
├── CLI/ # Console commands
└── API/ # API controllers
Shared Libraries¶
Creating a Composer Library¶
atraxion/transsmart-client/
├── src/
│ ├── TranssmartClient.php
│ ├── Request/
│ │ └── ShippingRateRequest.php
│ ├── Response/
│ │ └── ShippingRateResponse.php
│ └── Exception/
│ └── TranssmartException.php
├── tests/
├── composer.json
└── README.md
composer.json:
{
"name": "atraxion/transsmart-client",
"type": "library",
"require": {
"php": "^8.3",
"guzzlehttp/guzzle": "^7.0"
},
"autoload": {
"psr-4": {
"Atraxion\\Transsmart\\": "src/"
}
}
}
Creating a Symfony Bundle¶
atraxion/article-bundle/
├── src/
│ ├── AtraxionArticleBundle.php
│ ├── Entity/
│ │ ├── Article.php
│ │ └── ArticleBrand.php
│ ├── Repository/
│ │ └── ArticleRepositoryInterface.php
│ └── Service/
│ └── ArticleMatchingService.php
├── config/
│ └── services.yaml
├── tests/
└── composer.json
Development Workflow¶
- Write Gherkin feature - Define behavior in feature file
- Implement step definitions - Make tests runnable
- Write failing tests - TDD approach
- Implement feature - Make tests pass
- Refactor - Clean up while tests stay green
- Code review - PR review before merge
Environment Configuration¶
Environment Variables¶
# Database
DATABASE_URL=mysql://user:pass@localhost:3306/webshop
# Tenant
DEFAULT_TENANT_DOMAIN=tyrecloud.be
# External services
TRANSSMART_API_KEY=xxx
PAYNL_API_TOKEN=xxx
PAYNL_SERVICE_ID=xxx
DRIVERIGHT_API_KEY=xxx
ARTEEL_API_KEY=xxx
# ERP Middleware
ERP_MIDDLEWARE_URL=https://erp.internal/api
ERP_MIDDLEWARE_API_KEY=xxx
Configuration per Environment¶
- dev: Local development, debug enabled
- test: Automated testing, fixtures
- staging: Pre-production testing
- prod: Production, optimized