Compare commits

7 Commits

Author SHA1 Message Date
jens 98e4abcfb0 feat: add doctrine-fixtures-bundle & phpstan, restructure entity mapping
- Add doctrine/doctrine-fixtures-bundle (^4.3) for database fixtures
- Add phpstan/phpstan (^2.0) as dev dependency for static analysis
- Register DoctrineFixturesBundle in dev/test environments
- Move entity mapping to src/Data/Doctrine/Entity/Saatgut
- Update entity prefix to App\Data\Doctrine\Entity\Saatgut
- Change entity alias from App to Saatgut
2026-06-17 19:39:13 +02:00
jens 3226205889 Doku/php.md hinzugefügt 2026-06-17 17:09:06 +00:00
jens 0f9913faa2 Doku/architektur.md aktualisiert 2026-06-17 15:50:21 +00:00
jens ba87c9773c Doku/Tasks/02_datenmodel.md hinzugefügt 2026-06-14 16:24:32 +00:00
jens 2094b71335 Merge pull request 'Feat/01 infra' (#1) from feat/01_infra into main
Reviewed-on: #1
2026-06-14 16:23:10 +00:00
jens 5063ba80af kleinere Fixes 2026-06-14 18:20:12 +02:00
jens cd0d554fef Infra 2026-06-14 18:00:43 +02:00
51 changed files with 8633 additions and 21 deletions
+29
View File
@@ -0,0 +1,29 @@
# ==========================================
# GardenPlan Entwicklungsumgebung (Active)
# ==========================================
# --- Application ---
APP_ENV=dev
APP_SECRET=gardenplan_dev_secret_change_in_production_2024
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_HOSTS=^.*$
# --- Database (PostgreSQL) ---
POSTGRES_DB=gardenplan
POSTGRES_USER=symfony
POSTGRES_PASSWORD=changeme
DATABASE_URL="postgresql://symfony:changeme@database:5432/gardenplan?serverVersion=16&charset=utf8"
# --- Keycloak (OIDC / JWT) ---
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=gardenplan
KEYCLOAK_CLIENT_ID=symfony-app
KEYCLOAK_CLIENT_SECRET=changeme_client_secret
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=adminsecret
# --- Mailer (lokales Dev-Tool) ---
MAILER_DSN=null://null
# --- Messenger (Queue) ---
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=messenger_messages
+29
View File
@@ -0,0 +1,29 @@
# ==========================================
# GardenPlan Entwicklungsumgebung
# ==========================================
# --- Application ---
APP_ENV=dev
APP_SECRET=change_me_to_a_random_string_in_production
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_HOSTS=^.*$
# --- Database (PostgreSQL) ---
POSTGRES_DB=gardenplan
POSTGRES_USER=symfony
POSTGRES_PASSWORD=changeme
DATABASE_URL="postgresql://symfony:changeme@database:5432/gardenplan?serverVersion=16&charset=utf8"
# --- Keycloak (OIDC / JWT) ---
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=gardenplan
KEYCLOAK_CLIENT_ID=symfony-app
KEYCLOAK_CLIENT_SECRET=changeme_client_secret
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=adminsecret
# --- Mailer (lokales Dev-Tool) ---
MAILER_DSN=null://null
# --- Messenger (Queue) ---
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=messenger_messages
+17
View File
@@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
+50
View File
@@ -0,0 +1,50 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://symfony:changeme@database:5432/gardenplan?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=8f813531f1ad5443c687f70247b99c825771c9791460384f22cdb0353248f8f9
###< lexik/jwt-authentication-bundle ###
+26
View File
@@ -0,0 +1,26 @@
# ==========================================
# GardenPlan Entwicklung (lokal ohne Docker)
# ==========================================
# --- Application ---
APP_ENV=dev
APP_SECRET=gardenplan_dev_secret_change_in_production_2024
TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRUSTED_HOSTS=^.*$
# --- Database (PostgreSQL lokal) ---
DATABASE_URL="postgresql://symfony:changeme@database:5432/gardenplan?serverVersion=16&charset=utf8"
# --- Keycloak (OIDC / JWT) ---
KEYCLOAK_SERVER_URL=http://keycloak:8080
KEYCLOAK_REALM=gardenplan
KEYCLOAK_CLIENT_ID=symfony-app
KEYCLOAK_CLIENT_SECRET=changeme_client_secret
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=adminsecret
# --- Mailer (lokales Dev-Tool) ---
MAILER_DSN=null://null
# --- Symfony Cache/Log ---
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=messenger_messages
+14
View File
@@ -0,0 +1,14 @@
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) Fabien Potencier
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.
@@ -0,0 +1,58 @@
<?php declare(strict_types=1);
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250617184300_create_saatgut_tables extends AbstractMigration
{
public function getDescription(): string
{
return 'Erstellt die relationalen Tabellen für den Saatgut-Bestand: kategorie, pflanze, saatgut_bestand.';
}
public function up(Schema $schema): void
{
// 1. Kategorie-Tabelle
$this->addSql(<<<SQL
CREATE TABLE kategorie (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
farbe VARCHAR(7) DEFAULT NULL
);
SQL);
// 2. Pflanze-Tabelle
$this->addSql(<<<SQL
CREATE TABLE pflanze (
id SERIAL PRIMARY KEY,
art_name VARCHAR(200) NOT NULL,
sorten_name VARCHAR(200) DEFAULT NULL,
kategorie_id INTEGER DEFAULT NULL REFERENCES kategorie(id) ON DELETE SET NULL,
beschreibung TEXT DEFAULT NULL
);
SQL);
// 3. SaatgutBestand-Tabelle mit Unique Constraint (nutzer_id + pflanze_id)
$this->addSql(<<<SQL
CREATE TABLE saatgut_bestand (
id SERIAL PRIMARY KEY,
nutzer_id VARCHAR(255) NOT NULL,
pflanze_id INTEGER NOT NULL REFERENCES pflanze(id) ON DELETE CASCADE,
menge DOUBLE PRECISION NOT NULL,
kaufdatum DATE NOT NULL,
ablaufdatum DATE DEFAULT NULL,
notizen TEXT DEFAULT NULL,
CONSTRAINT unik_nutzer_pflanze UNIQUE (nutzer_id, pflanze_id)
);
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS saatgut_bestand');
$this->addSql('DROP TABLE IF EXISTS pflanze');
$this->addSql('DROP TABLE IF EXISTS kategorie');
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};
+7
View File
@@ -0,0 +1,7 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###
+25
View File
@@ -0,0 +1,25 @@
services:
###> doctrine/doctrine-bundle ###
database:
image: postgres:${POSTGRES_VERSION:-16}-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
# You should definitely change the password in production
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
POSTGRES_USER: ${POSTGRES_USER:-app}
healthcheck:
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- database_data:/var/lib/postgresql/data:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
volumes:
###> doctrine/doctrine-bundle ###
database_data:
###< doctrine/doctrine-bundle ###
+81
View File
@@ -0,0 +1,81 @@
{
"name": "symfony/skeleton",
"type": "project",
"license": "MIT",
"description": "A minimal Symfony project recommended to create bare bones applications",
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/doctrine-bundle": "*",
"doctrine/doctrine-fixtures-bundle": "^4.3",
"doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"symfony/console": "8.1.*",
"symfony/dotenv": "8.1.*",
"symfony/flex": "^2",
"symfony/framework-bundle": "8.1.*",
"symfony/messenger": "*",
"symfony/runtime": "8.1.*",
"symfony/security-bundle": "8.1.*",
"symfony/validator": "*",
"symfony/yaml": "8.1.*"
},
"config": {
"allow-plugins": {
"php-http/discovery": true,
"symfony/flex": true,
"symfony/runtime": true
},
"bump-after-update": true,
"sort-packages": true
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
},
"replace": {
"symfony/polyfill-ctype": "*",
"symfony/polyfill-iconv": "*",
"symfony/polyfill-php72": "*",
"symfony/polyfill-php73": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
],
"post-update-cmd": [
"@auto-scripts"
]
},
"conflict": {
"symfony/symfony": "*"
},
"extra": {
"symfony": {
"allow-contrib": false,
"require": "8.1.*"
}
},
"require-dev": {
"phpstan/phpstan": "^2.0"
}
}
+5117
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
];
+19
View File
@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null
+46
View File
@@ -0,0 +1,46 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Data/Doctrine/Entity/Saatgut'
prefix: 'App\Data\Doctrine\Entity\Saatgut'
alias: Saatgut
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system
+15
View File
@@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
@@ -0,0 +1,4 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
+22
View File
@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+10
View File
@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null
+39
View File
@@ -0,0 +1,39 @@
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
main:
lazy: true
provider: users_in_memory
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test:
security:
password_hashers:
# Password hashers are resource-intensive by design to ensure security.
# In tests, it's safe to reduce their cost to improve performance.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon
+11
View File
@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false
+5
View File
@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
# This file is the entry point to configure the routes of your app.
# Methods with the #[Route] attribute are automatically imported.
# See also https://symfony.com/doc/current/routing.html
# To list all registered routes, run the following command:
# bin/console debug:router
controllers:
resource: routing.controllers
+4
View File
@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
+3
View File
@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service
+23
View File
@@ -0,0 +1,23 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# See also https://symfony.com/doc/current/service_container/import.html
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+7
View File
@@ -0,0 +1,7 @@
parameters:
level: 8
paths:
- src
ignoreErrors:
# Symfony Kernel has some generic unused methods that are technically framework internals.
- '#Method App\\Kernel::getAllowedEnvs\(\) is unused#'
+9
View File
@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return static function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
@@ -0,0 +1,48 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Entity\Saatgut;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class KategorieEntity
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'integer')]
public ?int $id = null;
#[ORM\Column(type: 'string', length: 100, unique: true)]
private string $name;
#[ORM\Column(type: 'string', length: 7, nullable: true)]
private ?string $farbe = null;
public function __construct(string $name, ?string $farbe = null)
{
$this->name = $name;
$this->farbe = $farbe;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getFarbe(): ?string
{
return $this->farbe;
}
public function setFarbe(?string $farbe): self
{
$this->farbe = $farbe;
return $this;
}
}
@@ -0,0 +1,84 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Entity\Saatgut;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class PflanzeEntity
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'integer')]
public ?int $id = null;
#[ORM\Column(type: 'string', length: 200)]
private string $artName;
#[ORM\Column(type: 'string', length: 200, nullable: true)]
private ?string $sortenName = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $kategorieId = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $beschreibung = null;
public function __construct(
string $artName,
?string $sortenName = null,
?int $kategorieId = null,
?string $beschreibung = null,
) {
$this->artName = $artName;
$this->sortenName = $sortenName;
$this->kategorieId = $kategorieId;
$this->beschreibung = $beschreibung;
}
public function getId(): ?int
{
return $this->id;
}
public function getArtName(): string
{
return $this->artName;
}
public function getSortenName(): ?string
{
return $this->sortenName;
}
public function setSortenName(?string $sortenName): self
{
$this->sortenName = $sortenName;
return $this;
}
public function getKategorieId(): ?int
{
return $this->kategorieId;
}
public function setKategorieId(?int $kategorieId): self
{
$this->kategorieId = $kategorieId;
return $this;
}
public function getBeschreibung(): ?string
{
return $this->beschreibung;
}
public function setBeschreibung(?string $beschreibung): self
{
$this->beschreibung = $beschreibung;
return $this;
}
}
@@ -0,0 +1,118 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Entity\Saatgut;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'saatgut_bestand')]
#[ORM\UniqueConstraint(name: 'unik_nutzer_pflanze', columns: ['nutzer_id', 'pflanze_id'])]
class SaatgutBestandEntity
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: 'integer')]
public ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $nutzerId;
#[ORM\Column(type: 'integer')]
private int $pflanzeId;
#[ORM\Column(type: 'float')]
private float $menge;
#[ORM\Column(type: 'date')]
private \DateTimeInterface $kaufdatum;
#[ORM\Column(type: 'date', nullable: true)]
private ?\DateTimeInterface $ablaufdatum = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $notizen = null;
public function __construct(
string $nutzerId,
int $pflanzeId,
float $menge,
\DateTimeInterface $kaufdatum,
?\DateTimeInterface $ablaufdatum = null,
?string $notizen = null,
) {
$this->nutzerId = $nutzerId;
$this->pflanzeId = $pflanzeId;
$this->menge = $menge;
$this->kaufdatum = $kaufdatum;
$this->ablaufdatum = $ablaufdatum;
$this->notizen = $notizen;
}
public function getId(): ?int
{
return $this->id;
}
public function getNutzerId(): string
{
return $this->nutzerId;
}
public function getPflanzeId(): int
{
return $this->pflanzeId;
}
public function setPflanzeId(int $pflanzeId): self
{
$this->pflanzeId = $pflanzeId;
return $this;
}
public function menge(): float
{
return $this->menge;
}
public function setMenge(float $menge): self
{
$this->menge = $menge;
return $this;
}
public function kaufdatum(): \DateTimeInterface
{
return $this->kaufdatum;
}
public function setKaufdatum(\DateTimeInterface $kaufdatum): self
{
$this->kaufdatum = $kaufdatum;
return $this;
}
public function ablaufdatum(): ?\DateTimeInterface
{
return $this->ablaufdatum;
}
public function setAblaufdatum(?\DateTimeInterface $ablaufdatum): self
{
$this->ablaufdatum = $ablaufdatum;
return $this;
}
public function notizen(): ?string
{
return $this->notizen;
}
public function setNotizen(?string $notizen): void
{
$this->notizen = $notizen;
}
}
@@ -0,0 +1,29 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Repository\Saatgut;
use App\Data\Doctrine\Entity\Saatgut\KategorieEntity;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<KategorieEntity>
*/
class KategorieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, KategorieEntity::class);
}
/**
* @return string[]
*/
public function getAllNames(): array
{
return array_map(
fn(KategorieEntity $k) => $k->getName(),
$this->findAll()
);
}
}
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Repository\Saatgut;
use App\Data\Doctrine\Entity\Saatgut\PflanzeEntity;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PflanzeEntity>
*/
class PflanzeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PflanzeEntity::class);
}
/**
* Find plants by category ID (flat, no joins)
* @return PflanzeEntity[]
*/
public function findByKategorieId(int $kategorieId): array
{
return $this->createQueryBuilder('p')
->andWhere('p.kategorieId = :kategorieId')
->setParameter('kategorieId', $kategorieId)
->orderBy('p.artName', 'ASC')
->getQuery()
->getResult();
}
/**
* Find plant by exact art name and sort name (flat query)
*/
public function findByArtUndSorte(string $artName, string $sortenName): ?PflanzeEntity
{
return $this->createQueryBuilder('p')
->andWhere('p.artName = :artName')
->andWhere('p.sortenName = :sortenName')
->setParameter('artName', $artName)
->setParameter('sortenName', $sortenName)
->getQuery()
->getOneOrNullResult();
}
}
@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\Data\Doctrine\Repository\Saatgut;
use App\Data\Doctrine\Entity\Saatgut\SaatgutBestandEntity;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SaatgutBestandEntity>
*/
class SaatgutBestandRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SaatgutBestandEntity::class);
}
/**
* Get inventory items for a specific user (flat query via ID reference)
* @return SaatgutBestandEntity[]
*/
public function findByNutzerId(string $nutzerId): array
{
return $this->createQueryBuilder('s')
->andWhere('s.nutzerId = :nutzerId')
->setParameter('nutzerId', $nutzerId)
->orderBy('s.kaufdatum', 'DESC')
->getQuery()
->getResult();
}
/**
* Check if a specific user already has this plant in inventory (to enforce unique constraint semantically)
*/
public function existsByNutzerUndPflanze(string $nutzerId, int $pflanzeId): bool
{
return (bool) $this->createQueryBuilder('s')
->select('COUNT(s.id)')
->andWhere('s.nutzerId = :nutzerId')
->andWhere('s.pflanzeId = :pflanzeId')
->setParameter('nutzerId', $nutzerId)
->setParameter('pflanzeId', $pflanzeId)
->getQuery()
->getSingleScalarResult();
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
// $product = new Product();
// $manager->persist($product);
$manager->flush();
}
}
@@ -0,0 +1,32 @@
<?php declare(strict_types=1);
namespace App\DataFixtures;
use App\Data\Doctrine\Entity\Saatgut\KategorieEntity;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class KategorieFixtures extends Fixture
{
private const array KATEGORIEN = [
['name' => 'Gemüse', 'farbe' => '#4CAF50'],
['name' => 'Kräuter', 'farbe' => '#8BC34A'],
['name' => 'Blumen', 'farbe' => '#E91E63'],
['name' => 'Obst', 'farbe' => '#FF9800'],
];
public function load(ObjectManager $manager): void
{
foreach (self::KATEGORIEN as $data) {
$kategorie = new KategorieEntity(
name: $data['name'],
farbe: $data['farbe'],
);
$this->addReference("kategorie-{$data['name']}", $kategorie);
$manager->persist($kategorie);
}
$manager->flush();
}
}
@@ -0,0 +1,47 @@
<?php declare(strict_types=1);
namespace App\DataFixtures;
use App\Data\Doctrine\Entity\Saatgut\KategorieEntity;
use App\Data\Doctrine\Entity\Saatgut\PflanzeEntity;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class PflanzeFixtures extends Fixture implements DependentFixtureInterface
{
/**
* @return class-string<Fixture>[]
*/
public function getDependencies(): array
{
return [KategorieFixtures::class];
}
private const array PFLANZEN = [
['artName' => 'Tomate', 'sortenName' => 'Stelzer', 'kategorie' => 'Gemüse'],
['artName' => 'Paprika', 'sortenName' => 'Habanero', 'kategorie' => 'Gemüse'],
['artName' => 'Basilikum', 'sortenName' => 'Genovese', 'kategorie' => 'Kräuter'],
['artName' => 'Salbei', 'sortenName' => null, 'kategorie' => 'Kräuter'],
['artName' => 'Sonnenblume', 'sortenName' => 'Riesenblume', 'kategorie' => 'Blumen'],
['artName' => 'Erdbeere', 'sortenName' => 'Senga Sengana', 'kategorie' => 'Obst'],
];
public function load(ObjectManager $manager): void
{
foreach (self::PFLANZEN as $data) {
$kategorie = $this->getReference("kategorie-{$data['kategorie']}", KategorieEntity::class);
$pflanze = new PflanzeEntity(
artName: $data['artName'],
sortenName: $data['sortenName'] ?? null,
kategorieId: $kategorie->getId(),
beschreibung: "Katalogeintrag für {$data['artName']}",
);
$manager->persist($pflanze);
}
$manager->flush();
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
/**
* @return list<string> An array of allowed values for APP_ENV
*/
private function getAllowedEnvs(): array
{
return ['prod', 'dev', 'test'];
}
}
+159
View File
@@ -0,0 +1,159 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
}
},
"doctrine/doctrine-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "d39a3bd844edfe90c20ae520b804a3bf4f82b4ad"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "1f5514cfa15b947298df4d771e694e578d4c204d"
},
"files": [
"src/DataFixtures/AppFixtures.php"
]
},
"lexik/jwt-authentication-bundle": {
"version": "3.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.5",
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
},
"files": [
"config/packages/lexik_jwt_authentication.yaml"
]
},
"phpstan/phpstan": {
"version": "2.2.2"
},
"symfony/console": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"bin/console"
]
},
"symfony/flex": {
"version": "2.11",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/framework-bundle": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "8.1",
"ref": "312027aea160796a50bf2d185503afdb5d71f570"
},
"files": [
"config/packages/cache.yaml",
"config/packages/framework.yaml",
"config/preload.php",
"config/routes/framework.yaml",
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
]
},
"symfony/messenger": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/property-info": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
},
"files": [
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.4",
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
},
"files": [
"config/packages/security.yaml",
"config/routes/security.yaml"
]
},
"symfony/validator": {
"version": "8.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
}
}
+18
View File
@@ -0,0 +1,18 @@
Dokument 2: Datenmodell & Persistenz (Backend)
Ziel: Implementierung der relationalen Struktur zur Verwaltung des Saatgut-Bestands.
Entity-Definitionen (Doctrine ORM):
Kategorie: id, name (unique), farbe.
Pflanze: id, art_name, sorten_name, kategorie_id (FK → Kategorie), beschreibung.
SaatgutBestand: id, nutzer_id (String aus Keycloak), pflanze_id (FK → Pflanze), menge, kaufdatum, ablaufdatum, notizen.
Daten-Integrität:
Kaskadierendes Löschen: Wenn eine Kategorie gelöscht wird, muss entschieden werden, wie mit den Pflanzen verfahren wird (z.B. Setzen auf null oder Mitlöschen).
Unique Constraint für nutzer_id + pflanze_id, um doppelte Einträge derselben Sorte pro Nutzer zu vermeiden (optional, je nach Wunsch).
Akzeptanzkriterien:
Datenbank-Migrationen sind erstellt und erfolgreich ausgeführt.
Testdaten für den globalen Katalog (Kategorie & Pflanze) wurden importiert.
Die Beziehungen zwischen den Tabellen sind via Doctrine korrekt abgebildet.
+30 -20
View File
@@ -5,9 +5,9 @@ Das Ziel dieser Architektur ist eine strikte Trennung zwischen technischer Infra
### Grundregeln:
* **Unidirektionaler Fluss:** `UI` $\rightarrow$ `Logic` $\rightarrow$ `Data`. Ein Layer darf niemals Informationen aus einem übergeordneten Layer importieren.
* **Dependency Inversion:** Die `Logic`-Schicht definiert die Anforderungen (Interfaces). Die `Data`-Schicht implementiert diese. Die Logik ist somit unabhängig von der Datenbank-Technologie.
* **Model-Zentrierung:** Das Model ist das Herzstück der Applikation. Es ist ein anämisches POPO (Plain Old PHP Object) und wird primär im gesamten System verwendet.
* **Atomarität:** Business-Operationen sind atomar. Entweder alles wird gespeichert oder nichts (Transaction Management).
* **Dependency Inversion:** Die `Logic`-Schicht definiert die Anforderungen (Interfaces). Die `Data`-Schicht implementiert diese. Die Logik ist somit vollständig unabhängig von der Datenbank-Technologie oder externen APIs.
* **Model-Zentrierung:** Das Model ist das Herzstück der Applikation. Es ist ein anämisches POPO (Plain Old PHP Object) und dient als primärer Datenträger zwischen den Schichten.
* **Atomarität:** Business-Operationen sind atomar. Entweder alles wird gespeichert oder nichts (Transaction Management via `TransactionManagerInterface`).
---
@@ -19,40 +19,40 @@ Die UI-Schicht ist die dünne Eintrittspforte. Sie besitzt keine Geschäftslogik
* **Verantwortlichkeiten:**
* Mapping von Input auf **DTOs**.
* Syntaktische Validierung via Symfony Constraints am DTO.
* Grobe Autorisierungsprüfung (Rollen-basiert, z.B. `ROLE_USER`).
* Grobe Autorisierungsprüfung (Rollen-basiert).
* Aufruf des entsprechenden **UseCase**.
### 🔵 Logic Layer (Business Core)
Hier wird definiert, *was* das System tut. Die Logik ist in Orchestrierung und zustandslose Fachlogik unterteilt.
#### A. Orchestrierung & State
* **UseCase:** Der Dirigent eines Business-Prozesses. Er koordiniert den Ablauf: `Authorization` $\rightarrow$ `Validation` $\rightarrow$ `Calculation` $\rightarrow$ `Save`. Er steuert die Transaktionsgrenzen via `TransactionManagerInterface`.
* **Manager:** Zuständig für das **State Management**. Er implementiert das *Cache-Aside Pattern* (prüft Cache vor dem Provider) und steuert die Cache-Invalidierung nach Schreibvorgängen.
* **UseCase:** Der Dirigent eines Business-Prozesses. Er koordiniert: `Authorization` $\rightarrow$ `Validation` $\rightarrow$ `Calculation` $\rightarrow$ `Save`. Er steuert die Transaktionsgrenzen.
* **Manager:** Zuständig für das **State Management**. Implementiert das *Cache-Aside Pattern* (Prüfung Cache vor Provider) und steuert die Invalidierung via Cache-Tags nach Schreibvorgängen.
#### B. Spezialisierte Logik (Stateless Services)
Um "Fat Managers" zu vermeiden, wird Fachlogik in spezialisierte Klassen ausgelagert:
Zur Vermeidung von "Fat Managers" wird Fachlogik ausgelagert:
* **Calculators:** Pure Functions für Berechnungen $\rightarrow$ gibt Werte zurück.
* **Policies:** Business-Regelprüfungen $\rightarrow$ gibt `boolean` zurück.
* **Validators:** Komplexe Zustandsprüfungen $\rightarrow$ wirft `ValidationException`.
* **Validators:** Komplexe semantische Zustandsprüfungen $\rightarrow$ wirft `DomainException`.
* **Strategies:** Kapselung austauschbarer Algorithmen.
#### C. Domain Objekte
* **Models:** Die primären Datencontainer (z.B. `GardenPlan`). Anämisch und unabhängig von der DB.
* **Models:** Primäre Datencontainer (z.B. `GardenPlan`). Anämisch und unabhängig von der DB.
* **DTOs:** Transportobjekte zwischen UI und Logic.
* **Domain Events:** Dünne Ereignisse (ID & Typ), die via `DomainEventCollector` gesammelt und nach dem Commit gefeuert werden.
* **Domain Events:** Dünne Ereignisse, die manuell im UseCase nach erfolgreichem Commit getriggert werden.
### 🔴 Data Layer (Infrastructure)
Die technische Realisierung. Alles hier ist austauschbar, solange die Interfaces der Logic-Schicht erfüllt werden.
* **Provider / Processor:** Implementierungen der in der Logic definierten Interfaces. Provider = Lesen | Processor = Schreiben.
* **Entities:** Die technischen Repräsentationen für Doctrine (z.B. `GardenPlanEntity`).
* **Mappers:** Explizite Klassen, die zwischen `Entity` $\leftrightarrow$ `Model` transformieren.
* **Provider / Processor:** Implementierungen der Interfaces (`Provider` = Lesen | `Processor` = Schreiben).
* **Entities:** Technische Repräsentationen für Doctrine (z.B. `GardenPlanEntity`).
* **Mappers:** Explizite Klassen, die zwischen `Entity` $\leftrightarrow$ `Model` transformieren (KI-generiert/manuell).
---
## 3. Technischer Workflow & Pipeline
### Der Schreib-Pfad (Write Flow)
`UI (Controller/Command)` $\rightarrow$ `DTO` $\rightarrow$ `UseCase (Start Transaction)` $\rightarrow$ `Auth Check (Voter)` $\rightarrow$ `Validator` $\rightarrow$ `Policy` $\rightarrow$ `Calculator` $\rightarrow$ `Manager` $\rightarrow$ `Processor` $\rightarrow$ `Mapper` $\rightarrow$ `Entity` $\rightarrow$ `DB`.
`UI` $\rightarrow$ `DTO` $\rightarrow$ `UseCase (Start Transaction)` $\rightarrow$ `Auth Check` $\rightarrow$ `Validator` $\rightarrow$ `Policy` $\rightarrow$ `Calculator` $\rightarrow$ `Manager` $\rightarrow$ `Processor` $\rightarrow$ `Mapper` $\rightarrow$ `Entity` $\rightarrow$ `DB` $\rightarrow$ `Commit` $\rightarrow$ `Trigger Events`.
### Der Lese-Pfad (Read Flow)
`UI` $\rightarrow$ `UseCase` $\rightarrow$ `Manager (Cache check)` $\rightarrow$ `Provider Interface` $\rightarrow$ `Implementation` $\rightarrow$ `Mapper` $\rightarrow$ `Model` $\rightarrow$ `UI`.
@@ -61,13 +61,23 @@ Die technische Realisierung. Alles hier ist austauschbar, solange die Interfaces
## 4. Querschnittsfunktionen
### Transaktionssteuerung & Fehler
* **Transaction Management:** Der UseCase nutzt ein `TransactionManagerInterface`, um sicherzustellen, dass alle Änderungen innerhalb eines Prozesses atomar erfolgen.
* **Error Handling:** Eigene Domain-Exceptions in der Logic. Ein zentraler **Symfony ExceptionListener** mappt diese auf HTTP-Statuscodes (400, 403, 404, etc.).
### Dependency Injection & Composition Root
Um eine strikte Entkopplung zu gewährleisten, wird die `Logic`-Schicht niemals direkt auf konkrete Implementierungen referenzieren (kein hartcodiertes `#[Autowire(service: ...)]`).
* **Implicit Autowiring:** Bei einer einzigen Implementierung pro Interface nutzt Symfony das Standard-Autowiring.
* **Composition Root:** Die `services.yaml` fungiert als zentrale Konfigurationsinstanz, in der Interfaces auf konkrete Implementierungen gemappt werden (Alias/Bind), um die Infrastruktur von außen steuerbar zu machen.
### Asynchronität (Messenger)
* **Inbound:** `Message` $\rightarrow$ `Handler` $\rightarrow$ `DTO` $\rightarrow$ `UseCase`.
* **Outbound:** Nachrichten werden erst **nach** dem erfolgreichen Commit der Transaktion verschickt (Post-Commit Trigger), um "Ghost Notifications" zu vermeiden.
### Fehlermanagement & Exception-Handling
Ein zentrales System mappt fachliche Fehler auf HTTP-Standards:
* **Hierarchie:** Alle Fachfehler erben von einer Basis-`DomainException`.
* **Mapping:** Ein `Symfony ExceptionListener` fängt diese ab und mappt sie auf Statuscodes:
* `ResourceNotFoundException` $\rightarrow$ 404
* `AccessDeniedException` $\rightarrow$ 403
* `BusinessRuleViolationException` $\rightarrow$ 422/400
* **Format:** Die Antwort erfolgt im **RFC 7807 (Problem Details for HTTP APIs)** Format, um dem Frontend präzise, maschinenlesbare Fehler zu liefern.
### Transaktionssteuerung & Asynchronität
* **Transaction Management:** Abstrahiert über ein `TransactionManagerInterface`.
* **Messenger:** Outbound-Nachrichten werden erst nach erfolgreichem DB-Commit versendet, um inkonsistente Benachrichtigungen ("Ghost Notifications") zu vermeiden.
---
+68
View File
@@ -0,0 +1,68 @@
# PHP & Symfony Engineering Guidelines
## 1. Core PHP Standard
Wir schreiben modernen, typensicheren und expliziten Code. Ziel ist es, Fehler zur Compile-Zeit bzw. via statischer Analyse zu finden.
* **Strict Typing:** Jede Datei beginnt zwingend mit `declare(strict_types=1);`.
* **Typisierung:**
* Konsequentes Type Hinting für alle Parameter und Rückgabewerte (inkl. `void`).
* Nutzung von Union- und Intersection Types.
* `mixed` ist verboten, außer in absolut unvermeidbaren Generics.
* **Modern Features (PHP 8.2+):**
* **Readonly:** Standard für DTOs, Value Objects und Services (`readonly class`).
* **Constructor Promotion:** Nutzung zur Reduktion von Boilerplate.
* **Enums:** Einsatz anstelle von Konstanten oder Strings für feste Zustände.
* **Match Expression:** Bevorzugt gegenüber `switch`.
* **Anti-Magic:** Magic Methods (`__call`, `__get` etc.) und dynamische Properties sind **strikt verboten**.
## 2. Control Flow & Readability
Lesbarkeit schlägt akademische Perfektion. Code muss für andere Senioren ohne "Rätselraten" sofort verstehbar sein.
* **Control Flow:** Nutzung von **Early Returns** und **Guard Clauses**. Verschachtelte `if-else`-Strukturen sind zu vermeiden, um die kognitive Last gering zu halten.
* **Self-Explaining Code:** Namen müssen den Intent beschreiben (`calculateTotalTax()` statt `calc()`). Booleans beginnen mit `is`, `has` oder `can`.
* **Kommentare:** Kommentare werden nur eingesetzt, wenn:
1. Der Code aufgrund einer Sonderlösung **nicht selbsterklärend** ist (Dokumentation des "Warum").
2. **Arrays/Collections typisiert** werden müssen (`/** @var UserDTO[] $users */`), sofern PHPStan dies nicht anders erfassen kann.
## 3. Data Structures & Collections
Wir vermeiden "Mystery Meat"-Datenstrukturen zugunsten von Typsicherheit.
* **No Associative Arrays:** Die Nutzung von assoziativen Arrays als Pseudo-Objekte/Datencontainer ist verboten. Es werden immer **dedizierte Objekte (DTOs)** verwendet.
* **Collections:**
* **Transport:** Für einfache Listen, die nur durchiteriert werden, reichen typisierte Arrays (`array<T>`).
* **Domain-Logik:** Sobald eine Liste fachliche Logik besitzt (z.B. Filterung, Aggregation), wird eine dedizierte **Collection-Klasse** erstellt (implementiert `IteratorAggregate` und `Countable`).
## 4. Infrastructure & Symfony Implementation
Symfony ist der Adapter zur Außenwelt, nicht das Zentrum der Applikation.
* **Dependency Injection:** Ausschließlich **Constructor Injection**. Kein Service-Locator (`$this->container->get()`). DI erfolgt primär gegen Interfaces (Ports).
* **Controller:** "Thin Controller". Aufgaben sind: Request-Daten validieren/extrahieren $\rightarrow$ Application-Service aufrufen $\rightarrow$ Response transformieren. Keine Business-Logik im Controller.
* **Third-Party SDKs:** Alle externen Services/SDKs werden konsequent hinter einem **eigenen Interface (Port)** in der Infrastructure-Schicht gekapselt. Vendor-Klassen dürfen niemals den Application- oder Domain-Layer erreichen.
## 5. Persistence & Entity Design
Wir minimieren die Koppelung an das ORM, um Performance und Testbarkeit zu sichern.
* **Simple Entities:** Entitäten werden flach gehalten.
* **Avoid Joins:** Gejointe Entitäten (komplexe `@ManyToOne` / `@OneToMany`) sind grundsätzlich zu vermeiden. Referenzen erfolgen über **IDs**. Die Zusammenführung erfolgt explizit in den Repositories/Services.
* **Mapping:** Transformationen zwischen Infrastructure-Entities und Domain-Modellen erfolgen ausschließlich über dedizierte **Mapper-Klassen in der Dataschicht**.
## 6. Validation Pipeline (Shift-Left)
Fehler werden so früh wie möglich abgefangen:
`Request` $\rightarrow$ `DTO Asserts/Validation` $\rightarrow$ `Application Logic` $\rightarrow$ `Domain Complex Tests`.
## 7. Quality Gates & Workflow (Definition of Done)
Bevor Code in ein Review geht, müssen folgende lokale Checks erfolgreich durchlaufen sein:
1. **Syntax Check:** `php -l`
2. **Statische Analyse:** `phpstan analyse` (Ziel Level 8/9). *Pragmatismus-Regel:* Wenn Typisierung die Lesbarkeit massiv zerstört $\rightarrow$ Review-Dialog statt "Force-Typing".
3. **Automatisierte Tests:** `phpunit` (Unit, Integration, Functional).
---
### Die Golden Rules im Überblick:
1. **Kein Framework / Keine Vendor-Klassen im Domain.**
2. **Flache Entitäten / Referenzen über IDs.**
3. **Immutability by Default (`readonly`).**
4. **Keine assoziativen Arrays $\rightarrow$ Dedizierte Objekte.**
5. **Early Returns $\rightarrow$ Flacher Code.**
6. **Local Check: `php -l` $\rightarrow$ `phpstan` $\rightarrow$ `phpunit`.**
+9
View File
@@ -0,0 +1,9 @@
-- ==========================================
-- GardenPlan PostgreSQL Init Script
-- Erstellt Keycloak-Datenbank & -Benutzer
-- ==========================================
CREATE DATABASE keycloak_db;
CREATE USER keycloak WITH PASSWORD 'keycloak_secret';
GRANT ALL PRIVILEGES ON DATABASE keycloak_db TO keycloak;
ALTER USER keycloak CREATEDB;
+377
View File
@@ -0,0 +1,377 @@
{
"id": "gardenplan-realm",
"realm": "gardenplan",
"displayName": "GardenPlan",
"displayNameHtml": "<div class=\"kc-logo-text\"><span>GardenPlan</span></div>",
"notBefore": 0,
"defaultSignatureAlgorithm": "RS256",
"revokeRefreshToken": false,
"refreshTokenMaxReuse": 0,
"accessTokenLifespan": 3600,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 3600,
"ssoSessionMaxLifespan": 86400,
"ssoSessionIdleTimeoutRememberMe": 0,
"ssoSessionMaxLifespanRememberMe": 0,
"offlineSessionIdleTimeout": 2592000,
"offlineSessionMaxLifespanEnabled": false,
"offlineSessionMaxLifespan": 5184000,
"clientSessionIdleTimeout": 0,
"clientSessionMaxLifespan": 0,
"clientOfflineSessionIdleTimeout": 0,
"clientOfflineSessionMaxLifespan": 0,
"accessCodeLifespan": 60,
"accessCodeLifespanUserAction": 300,
"accessCodeLifespanLogin": 1800,
"actionTokenGeneratedByAdminLifespan": 43200,
"actionTokenGeneratedByUserLifespan": 3600,
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": false,
"verifyEmail": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": false,
"editUsernameAllowed": false,
"bruteForceProtected": false,
"permanentLockout": false,
"maxTemporaryLockouts": 0,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSlots": 144,
"quickLoginCheckMilliDetails": 200,
"bruteForceStrategy": "MULTI_USER",
"resetPasswordTimeout": 0,
"defaultRole": {
"id": "gardenplan-default-role",
"name": "default-roles-gardenplan",
"description": "${role_default-roles}",
"type": "DEFAULT",
"scope": "GLOBAL"
},
"requiredCredentials": [
"password"
],
"otpPolicyType": "totp",
"otpPolicyAlgorithm": "HmacSHA1",
"otpPolicyInitialCounter": 0,
"otpPolicyDigits": 6,
"otpPolicyLookAheadWindow": 1,
"otpPolicyPeriod": 30,
"otpSupportedApplications": [
"FreeOTP",
"Google Authenticator"
],
"localizationTexts": {},
"webAuthnChallengeUsage": "per-session",
"identityProviderAliases": {},
"users": [
{
"id": "test-user-001",
"username": "testuser",
"enabled": true,
"emailVerified": true,
"email": "testuser@gardenplan.local",
"credentials": [
{
"type": "password",
"value": "testpassword123!",
"temporary": false
}
],
"roles": {
"client": {
"symfony-app": [
"uma_authorization"
]
},
"realm": [
"default-roles-gardenplan",
"offline_access",
"uma_authorization"
]
}
}
],
"clients": [
{
"id": "symfony-app-client",
"clientId": "symfony-app",
"name": "Symfony GardenPlan API",
"description": "Backend REST-API Client für GardenPlan",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "changeme_client_secret",
"baseUrl": "http://localhost/",
"redirectUris": [
"http://localhost/*",
"http://127.0.0.1/*"
],
"webOrigins": [
"+"
],
"grantTypes": [
"authorization_code",
"refresh_token",
"client_credentials",
"implicit"
],
"standardFlowEnabled": true,
"implicitFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true,
"publicClient": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"client.secret.creation.time": "1700000000",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"login.jansendata": "{}"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
},
{
"id": "frontend-client",
"clientId": "gardenplan-frontend",
"name": "GardenPlan Frontend (React/Vue)",
"description": "Frontend SPA Client für GardenPlan",
"enabled": true,
"publicClient": true,
"baseUrl": "http://localhost:3000/",
"redirectUris": [
"http://localhost:3000/*",
"http://127.0.0.1:3000/*"
],
"webOrigins": [
"http://localhost:3000",
"http://127.0.0.1:3000"
],
"grantTypes": [
"authorization_code",
"implicit"
],
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"protocol": "openid-connect",
"attributes": {
"oidc.ciba.grant.enabled": "false",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"backchannel.logout.session.required": "true"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": true,
"defaultClientScopes": [
"web-origins",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
],
"clientScopes": [
{
"id": "scope-email",
"name": "email",
"description": "OpenID Connect built-in scope: email",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"consent.screen.text": "${emailScopeConsentText}",
"display.on.consent.screen": "true"
},
"icons": {},
"protocolMappers": [
{
"id": "mapper-email",
"name": "email",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email",
"jsonType.label": "String",
"multivalued": "false"
}
},
{
"id": "mapper-email-verified",
"name": "email verified",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "emailVerified",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "email_verified",
"jsonType.label": "boolean",
"multivalued": "false"
}
}
]
},
{
"id": "scope-profile",
"name": "profile",
"description": "OpenID Connect built-in scope: profile",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"consent.screen.text": "${profileScopeConsentText}",
"display.on.consent.screen": "true"
},
"icons": {},
"protocolMappers": [
{
"id": "mapper-profile-name",
"name": "full name",
"protocol": "openid-connect",
"protocolMapper": "oidc-full-name-mapper",
"consentRequired": false,
"config": {
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
},
{
"id": "mapper-username",
"name": "username",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "username",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "preferred_username",
"jsonType.label": "String"
}
}
]
},
{
"id": "scope-roles",
"name": "roles",
"description": "OpenID Connect scope for role mapping",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"icons": {},
"protocolMappers": [
{
"id": "mapper-client-roles",
"name": "client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "resource_access.${client_id}.roles",
"jsonType.label": "String"
}
},
{
"id": "mapper-realm-roles",
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"consentRequired": false,
"config": {
"multivalued": "true",
"user.attribute": "foo",
"access.token.claim": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String"
}
}
]
},
{
"id": "scope-web-origins",
"name": "web-origins",
"description": "OpenID Connect scope for web origins",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"consent.screen.text": "",
"display.on.consent.screen": "false"
},
"icons": {},
"protocolMappers": [
{
"id": "mapper-web-origins",
"name": "allowed web origins",
"protocol": "openid-connect",
"protocolMapper": "oidc-allowed-origins-mapper",
"consentRequired": false,
"config": {}
}
]
},
{
"id": "scope-offline-access",
"name": "offline_access",
"description": "OpenID Connect built-in scope: offline_access",
"protocol": "openid-connect",
"attributes": {
"consent.screen.text": "${offlineAccessScopeConsentText}",
"display.on.consent.screen": "true"
},
"icons": {}
}
],
"defaultDefaultClientScopes": [
"role_list",
"profile",
"email",
"roles",
"web-origins"
],
"defaultOptionalClientScopes": [
"offline_access",
"address",
"phone"
]
}
+56
View File
@@ -0,0 +1,56 @@
# ==========================================
# GardenPlan Nginx Konfiguration
# Symfony Reverse Proxy + API Endpoint
# ==========================================
server {
listen 80;
server_name _;
root /var/www/html/public;
# Performance-Settings
client_max_body_size 50M;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Default Symfony Backend (REST API)
location / {
try_files $uri /index.php$is_args$args;
}
# PHP-FPM Verbindung
location ~ ^/index\.php(/|$) {
fastcgi_pass php-fpm:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS off;
# PHP-FPM Timeout-Settings
fastcgi_connect_timeout 600;
fastcgi_send_timeout 600;
fastcgi_read_timeout 600;
}
# Verzeichnis-Zugriffe blockieren
location ~ /^.ht {
deny all;
}
# Assets Cache-Control
location ~* \.(jpg|jpeg|gif|png|webp|svg|css|js|woff2?|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Error Pages (optional)
error_page 404 /error.html;
error_page 500 502 503 504 /error.html;
}
+46
View File
@@ -0,0 +1,46 @@
# ==========================================
# GardenPlan PHP-FPM Dockerfile
# Symfony 7 / PHP 8.2 + erforderliche Extensions
# ==========================================
FROM php:8.4-fpm-bookworm AS base
# System-Abhängigkeiten installieren
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
libpq-dev \
libzip-dev \
zip \
unzip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libwebp-dev \
&& rm -rf /var/lib/apt/lists/*
# PHP Extensions kompilieren & installieren
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_pgsql xml zip mbstring opcache
# Composer installieren
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions intl apcu
# Node.js & npm für Frontend-Build (optional in Backend)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g pnpm
WORKDIR /var/www/html
# Symfony CLI installieren (optional, nützlich für Local-Tunnel etc.)
RUN curl -sS https://get.symfony.com/cli/installer | bash \
&& mv /root/.symfony5/bin/symfony /usr/local/bin/symfony
EXPOSE 9000
CMD ["php-fpm"]
+105
View File
@@ -0,0 +1,105 @@
# 🌿 GardenPlan Gartenplanung MVP
> Symfony-Basises für die Multi-Garten-Planung mit Pflanzen, Aussaat, Ernte & Aufgaben.
## 🏗️ Architektur
Strikte Schichtenarchitektur (Clean / Hexagonal):
**UI****Logic****Data**
Details: [Architektur-Dokumentation](Doku/architektur.md)
## 📋 Anforderungsprofil
[Hier ansehen](Doku/anforderungsprofil.md)
---
## 🚀 Schnellstart
### 1. DockerCompose-Setup starten
```bash
docker compose up -d
```
Startet folgende Services:
| Service | Port | Beschreibung |
|---------|------|--------------
| **nginx** | `80` / `443` | Webserver für Symfony REST-API
| **php-fpm** | (intern) | PHP 8.3 + Symfony Runtime
| **PostgreSQL** | `5432` | Datenbank (`gardenplan` + `keycloak_db`)
| **Keycloak** | `8080` | OIDC/JWT Identity Provider
### 🔑 Zugangsdaten (Dev)
| System | Benutzer | Passwort | URL |
|--------|----------|----------|-----|
| **Database** | `symfony` | `changeme` | `postgresql://database:5432/gardenplan` |
| **Keycloak Admin** | `admin` | `adminsecret` | http://localhost:8080 |
| **Test-Nutzer** | `testuser` | `testpassword123!` | (in Keycloak Realm `gardenplan`)
### 📦 Nach dem ersten Start
```bash
# In den PHP-Container wechseln
docker compose exec php-fpm bash
# Symfony Projekt setup
cd /var/www/html
composer install
# Datenbankmigrationen ausführen
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
# JWT-Schlüssel für Lexik Bundle generieren
php bin/console lexik:jwt:generate-keypair
```
---
## 📐 Ordnerstruktur
```
src/
├── UI/ # Controller, Commands, MessageHandlers
├── Logic/ # UseCases, Manager, Domain Services
│ ├── Domain/ # Models, DTOs, Validators, Policies
│ └── InfrastructureInterface/ # Provider / Processor Interfaces
└── Data/ # Doctrine Entities, Mappers, Implementations
```
---
## 📖 Aufgabenplan
| Task | Beschreibung | Status |
|------|-------------|--------|
| [01 Infrastruktur](Doku/Tasks/01-Infra.md) | Docker, DB, Keycloak Setup | ✅ In Arbeit |
| 02 Datenbank-Schema | Entities + Migrationen | ⏳ Offen |
| 03 Planning-Domain | GardenPlan Model, UseCases | ⏳ Offen |
| 04 REST API | Controller + DTOs | ⏳ Offen |
| 05 Auth & RBAC Keycloak Integration | ⏳ Offen |
---
## 🔧 Entwicklung
```bash
# Logs einsehen
docker compose logs -f php-fpm
docker compose logs -f nginx
# Container neu starten
docker compose restart php-fpm
# Alles stoppen
docker compose down
```
## .env-Variabeln kopieren & anpassen. Siehe `.env`.
---
**🌱 Happy Gardening!**
+121
View File
@@ -0,0 +1,121 @@
# ==========================================
# GardenPlan Docker Compose (Development)
# ==========================================
services:
# --------------------------------------------------
# PHP-FPM Symfony Backend
# --------------------------------------------------
php-fpm:
build:
context: ./Infra/php-fpm
dockerfile: Dockerfile
container_name: gardenplan-php-fpm
restart: unless-stopped
working_dir: /var/www/html
volumes:
- ./Backend:/var/www/html:z
- phpsocket:/var/run/php
networks:
- gardenplan-network
depends_on:
database:
condition: service_healthy
# --------------------------------------------------
# Nginx Webserver & Reverse Proxy
# --------------------------------------------------
nginx:
image: nginx:1.25-alpine
container_name: gardenplan-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:z
- ./Backend/public:/var/www/html/public:ro,z
- nginx_cache:/var/cache/nginx
networks:
- gardenplan-network
depends_on:
- php-fpm
# --------------------------------------------------
# PostgreSQL Datenbank
# --------------------------------------------------
database:
image: postgres:16-alpine
container_name: gardenplan-database
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-gardenplan}
POSTGRES_USER: ${POSTGRES_USER:-symfony}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
- ./Infra/database/init:/docker-entrypoint-initdb.d:z
networks:
- gardenplan-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-gardenplan}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# --------------------------------------------------
# Keycloak Identity Provider (OIDC / JWT)
# --------------------------------------------------
keycloak:
image: quay.io/keycloak/keycloak:24.0
container_name: gardenplan-keycloak
restart: unless-stopped
ports:
- "8080:8080"
environment:
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-adminsecret}
KC_DATABASE: postgres
KC_DATABASE_URL: jdbc:postgresql://database:5432/keycloak_db
KC_DATABASE_USERNAME: keycloak
KC_DATABASE_PASSWORD: keycloak_secret
KC_HTTP_RELATIVE_PATH: /auth
KC_HOSTNAME: localhost
KC_HOSTNAME_STRICT: "false"
KC_FEATURES: scripts
command: start-dev
volumes:
- ./Infra/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:z
networks:
- gardenplan-network
depends_on:
database:
condition: service_healthy
# --------------------------------------------------
# Keycloak Database (getrennt, falls gewünscht)
# Hier verwenden wir die Haupt-DB mit eigenem Schema
# Alternative: extra DB-Container hier vereinfacht
# --------------------------------------------------
# ==========================================
# Volumes
# ==========================================
volumes:
postgres_data:
driver: local
phpsocket:
driver: local
nginx_cache:
driver: local
# ==========================================
# Networks
# ==========================================
networks:
gardenplan-network:
driver: bridge