Slide 1

Slide 1 text

Modernisation Progressive d’Applications PHP 19 septembre 2024

Slide 2

Slide 2 text

@hhamon / [email protected] Hugo HAMON Consultant PHP / Symfony @ KODERO Symfony Certified Expert Developer ex-SensioLabs

Slide 3

Slide 3 text

Quelle nécessité à vouloir moderniser une base de code “legacy” ?

Slide 4

Slide 4 text

Qu’est-ce que le “code legacy” ? Celui dont vous n’êtes pas à l’origine ! Celui que vous venez tout juste d’écrire " Celui qui n’est pas testé # Celui qui fonctionne sur des technologies plus supportées $ Celui qui fonctionne en production mais qu’on a peur de changer %

Slide 5

Slide 5 text

Raisons pour opérer un changement Ajouter une nouvelle fonctionnalité Corriger un bug persistant et impactant Améliorer / simplifier le “design” général Optimiser les performances Pérenniser l’application dans le temps Remplacer une brique par une plus moderne Faire des montées en version de l’infrastructure … https://unsplash.com/@clemono

Slide 6

Slide 6 text

Laisser le code dans un meilleur état que celui dans lequel vous l’avez trouvé Quels gains pour la tech ? Faciliter les futurs changements du code Réduire le risque de bugs Se concentrer davantage sur l’apport de valeur pour le métier https://unsplash.com/@orrbarone

Slide 7

Slide 7 text

Quelques types de changement Changement purement syntaxique (styles) Correction d’un bug Extraction de code dupliqué Remplacement d’une dépendance plus supportée Intégration d’une nouvelle fonctionnalité Abstraction de certains composants Ajout de tests unitaires ou fonctionnels Changement au niveau de l’infrastructure

Slide 8

Slide 8 text

Points de vigilance ! Impacts sur des services tiers https://unsplash.com/@vinic_ Impacts sur le modèle de données Changements entraînant une cassure de compatibilité pour les autres Impacts sur le business

Slide 9

Slide 9 text

Arbitrages Niveau d’effort à mettre en œuvre ? Quels impacts pour le business ? Quels impacts pour la tech ? Quel investissement vs économie réalisée ? https://unsplash.com/@imchenyf

Slide 10

Slide 10 text

Continuer à délivrer de la valeur https://unsplash.com/@nathan_cima Définir une roadmap avec les équipes métier Planifier un plan d’action longtemps à l’avance Communiquer avec toutes les parties concernées Avoir un environnement de préproduction Utiliser des feature flags pour beta tester S’outiller un maximum

Slide 11

Slide 11 text

Sentry https://sentry.io

Slide 12

Slide 12 text

Les tests automatisés au cœur de la stratégie de modernisation

Slide 13

Slide 13 text

“Code without tests is bad code. It doesn’t matter how well-written it is; it doesn’t matter how pretty or object-oriented or well-encapsulated it is.” “With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don’t know if our code is getting better or worse.” — Michael C. Feathers https://www.pmi.org/learning/library/legacy-it-systems-upgrades-11443

Slide 14

Slide 14 text

Tests Unitaires “ En programmation informatique, le test unitaire est une procédure permettant de vérifier le bon fonctionnement d'une partie précise d'un logiciel ou d'une portion d'un programme (appelée « unité » ou « module »). ” — Wikipedia https://unsplash.com/@alexcioaba

Slide 15

Slide 15 text

Fast Rapidité d’exécution de la suite de tests L’ordre d’exécution n’a pas d’importance Indépendance vis-à-vis de l’environnement Répétition à l’infini dans les mêmes conditions Résultat binaire : réussite ou échec Couverture du “happy path” à minima Couverture des cas d’exception / d’erreur Couverture des cas à la marge … Isolated Repeatable Self-Validating Thorough

Slide 16

Slide 16 text

Model Class class Loan { // ... getter + setter methods public function getInstallmentsValue(): float { $borrowedValue = $this->getBorrowedValue(); $installments = $this->getTotalInstallments(); $fee = $this->getMonthlyFee(); return ($borrowedValue / $installments) * (1 + ($fee / 100)); } }

Slide 17

Slide 17 text

PHPUnit final class LoanTest extends TestCase { public function testGetLoanInstallmentValue(): void { $customer = new Customer(); $loan = new Loan(); $loan->setCustomer($customer); $loan->setBorrowedValue(10_000); $loan->setTotalInstallments(60); $loan->setMonthlyFee(3.25); self::assertSame($customer, $loan->getCustomer()); self::assertSame(10_000, $loan->getBorrowedValue()); self::assertSame(3.25, $loan->getMonthlyFee()); self::assertSame(60, $loan->getTotalInstallments()); // Expecting a decimal amount with cents self::assertSame(172.08, $loan->getInstallmentsValue()); } }

Slide 18

Slide 18 text

PHPUnit - Test Suite

Slide 19

Slide 19 text

Model Class class Loan { // ... public function getInstallmentsValue(): float { $borrowedValue = $this->getBorrowedValue(); $installments = $this->getTotalInstallments(); $ratio = 1 + $this->getMonthlyFee() * 0.01; $amount = ($borrowedValue / $installments) * $ratio; return round($amount, 2, PHP_ROUND_HALF_UP); } }

Slide 20

Slide 20 text

PHPUnit - Test Suite

Slide 21

Slide 21 text

Tests “End-to-End” “ Les tests de bout-en-bout permettent de valider le système dans son ensemble en prenant en compte toutes les couches applicatives. ” https://unsplash.com/@timmossholder

Slide 22

Slide 22 text

Avantages Idéal pour tester le comportement extérieur Adapté pour le “smoke testing” Exécution de toutes les couches applicatives Validation de l’UI et des interactions Inconvénients Complexité de la mise en place Lenteur d’exécution (réseau, IO, etc.) Plus contraignant à écrire également

Slide 23

Slide 23 text

Panther use Symfony\Component\Panther\Client; require __DIR__.'/vendor/autoload.php'; $client = Client::createChromeClient(); // Interact with the UI elements $client->request('GET', 'https://api-platform.com'); $client->clickLink('Getting started'); // Wait for an element to be present in the DOM (even if hidden) $crawler = $client->waitFor('#installing-the-framework'); // Alternatively, wait for an element to be visible $crawler = $client->waitForVisibility('#installing-the-framework'); echo $crawler->filter('#installing-the-framework')->text(); // Yeah, screenshot! $client->takeScreenshot('screen.png');

Slide 24

Slide 24 text

Behat + Gherkin Feature: In order to have a localized experience,
 users must be able to switch between supported languages. Scenario: A user is able to switch to English language. Given a guest user visits the French speaking website When they switch language to "english" Then they see "Create your account" as page title Scenario: A user is able to switch to French language. Given a guest user visits the English speaking website When they switch language to "français" Then they see "Créez votre compte" as page title

Slide 25

Slide 25 text

Behat + Gherkin final class SwitchLanguageContext implements Context { private Client $client; private Crawler $crawler; public function __construct() { $this->client = Client::createChromeClient(); } #[Given('a guest user visits the English speaking website')] public function guestUserVisitsTheEnglishSpeakingWebsite(): void { $this->crawler = $this->client->request('GET', 'https://test.website.com'); } #[When('they switch language to :language')] public function theySwitchLanguageTo(string $language): void { $this->crawler = $this->client->clickLink($language); } #[Then('they see :title as page title')] public function theySeeAsPageTitle(string $title): void { if ($title !== $this->crawler->filter('title')->text()) { throw new \Exception('Title mismatch'); } } }

Slide 26

Slide 26 text

PHP — Un écosystème mature, standardisé et doté d’outils professionnels

Slide 27

Slide 27 text

“ La gestion des dépendances, quel enfer… ” “ Mais c’est quoi tous ces include / require ?! ” https://unsplash.com/@nate_dumlao “ Si seulement je pouvais exécuter tous ces scripts en une seule commande… ”

Slide 28

Slide 28 text

Composer { "type": "project", "license": "proprietary", "autoload": { "psr-4": { "App\\": "src/" } }, "autoload-dev": { "psr-4": { "App\\Tests\\": "tests/" } } }

Slide 29

Slide 29 text

Composer { "require": { "php": ">=7.2", "league/flysystem": "^2.3.2", "moneyphp/money": "^3", "pagerfanta/core": "*" } }

Slide 30

Slide 30 text

Composer { "require": { "php": ">=7.2", "league/flysystem": "^2.5.0", "moneyphp/money": "^3.3.3", "pagerfanta/core": "^3.8.0" } } $ php composer bump

Slide 31

Slide 31 text

Dependabot https://github.com/dependabot Renovate.js https://github.com/renovatebot

Slide 32

Slide 32 text

Composer { "require": { "php": ">=7.2", "ext-iconv": "*", "ext-imap": "*", "ext-mbstring": "*", "ext-pdo": "*", "ext-xsl": "*", ... } } $ php composer require ext-iconv ext-imap ext-mbstring …

Slide 33

Slide 33 text

Composer { "scripts": { "app:install-dev": [ "@composer database:reset-dev", "@composer frontend:build-dev" ], "database:reset-dev": [ "@php bin/console d:d:d --if-exists --force -n -e dev", "@php bin/console d:d:c --if-not-exists -n -e dev", "@php bin/console d:m:m --all-or-nothing -n -e dev", "@php bin/console d:f:l --purge-with-truncate -n -e dev" ], "frontend:build-dev": [ "@php bin/console tailwind:build -e dev", "@php bin/console sass:build -e dev", "@php bin/console asset-map:compile -e dev" ] } } $ php composer app:install-dev

Slide 34

Slide 34 text

“ WOW ! Ces nouvelles fonctions ajoutées à PHP 8 nous simplifieraient tellement le code ! ” https://unsplash.com/@1nimidiffa_ “ Quel dommage que nous soyons encore en PHP 7 en prod… ”

Slide 35

Slide 35 text

Polyfills symfony/polyfill-php54 symfony/polyfill-php55 symfony/polyfill-php56 symfony/polyfill-php70 symfony/polyfill-php71 symfony/polyfill-php72 symfony/polyfill-php73 symfony/polyfill-php74 symfony/polyfill-php80 symfony/polyfill-php81 symfony/polyfill-php82 symfony/polyfill-php83 symfony/polyfill-php84 symfony/polyfill-apcu symfony/polyfill-ctype symfony/polyfill-iconv symfony/polyfill-intl-grapheme symfony/polyfill-intl-idn symfony/polyfill-intl-icu symfony/polyfill-intl-messageformatter symfony/polyfill-mbstring symfony/polyfill-util symfony/polyfill-uuid

Slide 36

Slide 36 text

Symfony Polyfills $text = <<

Slide 37

Slide 37 text

Symfony Polyfills { "require": { "php": ">=7.1", "symfony/polyfill-php72": "^1.31.0", "symfony/polyfill-php73": "^1.31.0", "symfony/polyfill-php74": "^1.31.0", "symfony/polyfill-php80": "^1.31.0", "symfony/polyfill-php81": "^1.31.0", "symfony/polyfill-php82": "^1.31.0", "symfony/polyfill-php83": "^1.31.0", "symfony/polyfill-php84": "^1.31.0" } }

Slide 38

Slide 38 text

Symfony Polyfills if (str_starts_with($text, 'PHP is a popular')) { echo 'Text starts with "PHP is a popular scripting language"…'; } if (str_contains($text, 'from your blog & to the most')) { echo 'Text contains "from your blog & to the most" string.'; } if (str_ends_with($text, 'in the world.')) { echo 'Text ends with "in the world." string.'; } $ php71 polyfill-php80.php Text starts with "PHP is a popular scripting language" string. Text contains "from your blog & to the most" string. Text ends with "in the world." string.

Slide 39

Slide 39 text

“ Quelle pagaille ! Il n’y a aucune cohérence d’un fichier à l’autre ! ” https://unsplash.com/@xavi_cabrera “ Harmonisons le style de codage une bonne fois pour toutes ”

Slide 40

Slide 40 text

ECS https://github.com/easy-coding-standard/easy-coding-standard

Slide 41

Slide 41 text

Configuration (1/3) return ECSConfig::configure() ->withPaths([ __DIR__ . '/config', __DIR__ . '/src', __DIR__ . '/tests', ]) ->withSkip([ __DIR__ . '/config/secrets/', ArrayOpenerAndCloserNewlineFixer::class, MethodChainingNewlineFixer::class, ]) ; ecs.php

Slide 42

Slide 42 text

return ECSConfig::configure() ->withPreparedSets( psr12: true, symplify: true, arrays: true, comments: true, docblocks: true, spaces: true, namespaces: true, controlStructures: true, phpunit: true, strict: true, cleanCode: true, ) ; ecs.php Configuration (2/3)

Slide 43

Slide 43 text

return ECSConfig::configure() ->withRules([ NoUnusedImportsFixer::class, ]) ->withSkip([ DeclareStrictTypesFixer::class, ]) ->withConfiguredRule(GlobalNamespaceImportFixer::class, [ 'import_classes' => true, ]) ->withConfiguredRule(MethodArgumentSpaceFixer::class,[ 'on_multiline' => 'ensure_fully_multiline', 'attribute_placement' => 'same_line', ]) ; ecs.php Configuration (3/3)

Slide 44

Slide 44 text

EasyCodingStandards

Slide 45

Slide 45 text

“ J’aimerais bien améliorer mon code legacy mais par où je commence ? ” https://unsplash.com/@brookecagle “ Si seulement je pouvais avoir une liste de problèmes à corriger… ”

Slide 46

Slide 46 text

PHPStan https://phpstan.org/

Slide 47

Slide 47 text

PHPStan https://tomasvotruba.com/tools

Slide 48

Slide 48 text

PHP Stan { ... "require-dev": { ... "phpstan/phpstan": "^1.12.3", "phpstan/extension-installer": "^1.4.3", "phpstan/phpstan-deprecation-rules": "^1.2.1", "phpstan/phpstan-doctrine": "^1.5.3", "phpstan/phpstan-phpunit": "^1.4.0", "phpstan/phpstan-strict-rules": "^1.6.0", "phpstan/phpstan-symfony": "^1.4.9" }, "config": { "allow-plugins": { "phpstan/extension-installer": true } } }

Slide 49

Slide 49 text

PHP Stan includes: - phpstan-baseline.neon parameters: level: max paths: - bin/ - config/ - public/ - src/ excludePaths: - src/Migrations/ checkAlwaysTrueInstanceof: true checkAlwaysTrueStrictComparison: true checkExplicitMixedMissingReturn: true reportWrongPhpDocTypeInVarTag: true treatPhpDocTypesAsCertain: false phpstan.neon

Slide 50

Slide 50 text

$ php vendor/bin/phpstan --generate-baseline Static Code Analysis

Slide 51

Slide 51 text

PHP Stan parameters: ignoreErrors: - message: "#^Method App\\\\Controller\\\\Authenticator\\:\\:login\\(\\) has no return type specified\\.$#" count: 1 path: src/Controller/Authenticator.php - message: "#^Method App\\\\Controller\\\\Authenticator\\:\\:logout\\(\\) has no return type specified\\.$#" count: 1 path: src/Controller/Authenticator.php - message: "#^Method App\\\\Controller\\\\Customer\\:\\:edit\\(\\) has no return type specified\\.$#" count: 1 path: src/Controller/Customer.php - message: "#^Method App\\\\Controller\\\\Customer\\:\\:edit\\(\\) has parameter \\$id with no type specified\\.$#" count: 1 path: src/Controller/Customer.php - message: "#^Method App\\\\Controller\\\\Customer\\:\\:handleCreationFormSubmission\\(\\) has no return type specified\\.$#" count: 1 path: src/Controller/Customer.php - message: "#^Method App\\\\Controller\\\\Customer\\:\\:handleCreationFormSubmission\\(\\) has parameter \\$form with ...” count: 1 path: src/Controller/Customer.php - message: "#^Method App\\\\Controller\\\\Customer\\:\\:handleEditFormSubmission\\(\\) has no return type specified\\.$#" count: 1 path: src/Controller/Customer.php phpstan-baseline.neon

Slide 52

Slide 52 text

“ Ça va prendre un temps fou pour tout fixer ! ” https://unsplash.com/@odissei “ Si seulement on pouvait automatiser certains changements… ”

Slide 53

Slide 53 text

PHPStorm

Slide 54

Slide 54 text

Rector https://getrector.com/ https://getrector.com

Slide 55

Slide 55 text

Rector Ajout des typages Indentation Annotations -> Attributs Retrait de code mort Constructor property promotion Syntaxe moderne (array, closures, etc.) “ Early returns ” Privatisation des attributs et méthodes Renommage de fonctions et méthodes Migration pour Symfony, Twig, PHPUnit, Laravel, etc. Extensibilité : création / import de règles tierces …

Slide 56

Slide 56 text

Rector PHP { ... "require-dev": { ... "rector/rector": "^1.2.0" } }

Slide 57

Slide 57 text

Rector PHP declare(strict_types=1); use Rector\Config\RectorConfig; return RectorConfig::configure() ->withParallel() ->withPaths([ __DIR__ . '/src', ]) ->withSkip([ __DIR__ . '/src/Migrations', ]) ->withAttributesSets(symfony: true, doctrine: true) ->withPhp74Sets() ->withPreparedSets(typeDeclarations: true); rector.php

Slide 58

Slide 58 text

$ php vendor/bin/rector ——dry—run Rector PHP

Slide 59

Slide 59 text

Remaniement du code

Slide 60

Slide 60 text

Refactoring Remaniement Organiser le code par métier / domaine Employer la nomenclature du métier Exposer des constructeurs sémantiques Favoriser l’usage des ENUM Favoriser l’inversion des dépendances Définir des interfaces claires Eviter les modèles anémiques Contrôler les paramètres d’entrée Intégrer des librairies éprouvées …

Slide 61

Slide 61 text

Organisation par métier https://unsplash.com/@odissei Pour mieux identifier les principaux modules qui cohabitent.

Slide 62

Slide 62 text

Domaines src "## Controller $ "## Authenticator.php $ "## ... $ &## User.php "## Entity $ "## Customer.php $ "## Helper.php $ "## Installment.php $ "## InstallmentPeriod.php $ "## InstallmentStatus.php $ "## Loan.php $ "## Role.php $ &## User.php "## Repository $ "## CustomersRepository.php $ "## HelperRepository.php $ "## InstallmentPeriodsRepository.php $ "## InstallmentStatusRepository.php $ "## InstallmentsRepository.php $ "## LoansRepository.php $ "## RolesRepository.php $ &## UserRepository.php &## Service "## Calculator.php "## Customer.php "## Helper.php "## Installment.php "## Loan.php "## Profit.php &## User.php

Slide 63

Slide 63 text

Une banque qui vend des services financiers de crédit à des clients ? Quels types de clients ? Quels types de crédits ? Une compagnie d’assurance qui vend des produits d’assurance de crédit à des clients souscripteurs ? Une société de rachat de crédit ? Une société de vente de crédits à la consommation à des clients particuliers ? Une application de suivi des états financiers d’un client particulier ou professionnel ? Une application qui permet de prêter de l’argent entre amis proches ou membres d’une même famille ? Quel est le métier de cette base de code ?

Slide 64

Slide 64 text

Domaines src "## Application $ "## Controller $ $ "## Authenticator.php $ $ "## Customer.php $ $ "## CustomerHistoric.php $ $ "## Installment.php $ $ "## Loan.php $ $ "## Profile.php $ $ "## Profit.php $ $ &## User.php $ "## DataFixtures $ $ &## ORM $ $ &## AppFixtures.php $ "## Kernel.php $ &## Migrations $ "## Version20190103002150.php $ "## Version20190113221724.php $ "## Version20190124120652.php $ "## Version20190212012115.php $ &## Version20190212013033.php "## BankingServices "## FinancialReporting &## User

Slide 65

Slide 65 text

Domaines src "## FinancialServices $ "## Calculator.php $ "## Customer.php $ "## Entity $ $ "## Customer.php $ $ "## Helper.php $ $ "## Installment.php $ $ "## InstallmentPeriod.php $ $ "## InstallmentStatus.php $ $ &## Loan.php $ "## Helper.php $ "## Installment.php $ "## Loan.php $ &## Repository $ "## CustomersRepository.php $ "## HelperRepository.php $ "## InstallmentPeriodsRepository.php $ "## InstallmentStatusRepository.php $ "## InstallmentsRepository.php $ &## LoansRepository.php "## FinancialReporting $ &## Profit.php &## User "## Entity $ "## Role.php $ &## User.php "## Repository $ "## RolesRepository.php $ &## UserRepository.php &## User.php

Slide 66

Slide 66 text

Nomenclature du métier https://unsplash.com/@waldemarbrandt67w Pour nommer les différents éléments du code d’après leur vocable issu du métier.

Slide 67

Slide 67 text

Nommage // Financial Reporting class Profit { // ... public function findAll(): array { $installmentsPerMonth = []; foreach ($this->installmentRepository->findAllPaid() as $installment) { $month = date('n', $installment->getDueDate()->getTimestamp()); if (! isset($installmentsPerMonth[$month])) { $installmentsPerMonth[$month] = []; } $installmentsPerMonth[$month][] = $installment; } $formattedData = []; foreach ($installmentsPerMonth as $month => $installments) { $formattedData[] = [ 'month' => $month, 'year' => date('Y', $installments[0]->getDueDate()->getTimestamp()), 'profit' => $this->sumInstallments($installments), ]; } return $formattedData; } }

Slide 68

Slide 68 text

Nommage class ComputeYearlyLoanInstallmentReporting { // ... /** * @return array{month: string, year: string, profit: float}[] */ public function __invoke(int $year): array { // ... } }

Slide 69

Slide 69 text

Nommage class ComputeYearlyLoanInstallmentReporting { // ... /** * @return array{month: string, year: string, profit: float}[] */ public function __invoke(string $year): array { // ... } }

Slide 70

Slide 70 text

Nommage final readonly class YearlyLoanInstallmentReporting { /** @var array */ private array $revenues = [ '01' => 0, // ... '12' => 0, ]; public function __construct(private string $year) {} public function increaseMonthlyRevenue(string $month, float $amount): void { $this->revenues[$month] += $amount; } /** * @return array */ public function toArray(): array { $results = []; foreach ($this->revenues as $month => $revenue) { $results[] = [ 'year' => $this->year, 'month' => $month, 'profit' => $revenue, ]; } return $results; } }

Slide 71

Slide 71 text

Nommage class ComputeYearlyLoanInstallmentReporting { // ... /** * @return array */ public function __invoke(int $year): array { $reporting = new YearlyLoanInstallmentReporting($year); foreach ($this->installmentRepository->findYearlyPaid($year) as $installment) { $reporting->increaseMonthlyRevenue( $installment->getDueDate()->format('m'), $installment->getValue(), // to be renamed to getAmount? ); } return $reporting->toArray(); } }

Slide 72

Slide 72 text

Eviter les modèles anémiques https://unsplash.com/@pattib Exposer des méthodes du métier sur les entités du modèle de données.

Slide 73

Slide 73 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $installmentAmount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = (new Installment()) ->setLoan($loan) ->setDueDate(new DateTimeImmutable('2024-10-05')) ->setAmount($installmentAmount) ->setStatus(InstallmentStatus::DUE); // Each day, a cronjob runs to update the status of the due/overdue installments $receivedPaymentDate = ...; if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->setPaymentDate($receivedPaymentDate); $installment->setStatus(InstallmentStatus::PAID); } // If payment is still unpaid after due date, its marked overdue $today = new DateTimeImmutable('today'); if ($installment->getStatus() === InstallmentStatus::DUE && $today > $installment->getDueDate()) { $installment->setStatus(InstallmentStatus::OVERDUE); }

Slide 74

Slide 74 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $installmentAmount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = (new Installment()) ->setLoan($loan) ->setDueDate(new DateTimeImmutable('2024-10-05')) ->setAmount($installmentAmount) ->setStatus(InstallmentStatus::DUE);

Slide 75

Slide 75 text

Modèle Métier class Installment { public function __construct( private readonly Loan $loan, private readonly InstallmentStatus $status, private readonly DateTimeImmutable $dueDate, private readonly float $amount, ) { } }

Slide 76

Slide 76 text

Modèle Métier class Installment { public static function due( Loan $loan, DateTimeImmutable $dueDate, float $amount ): self { return new self( loan: $loan, status: InstallmentStatus::DUE, dueDate: $dueDate, Amount: $amount, ); } }

Slide 77

Slide 77 text

Modèle Métier class Installment { public static function due( Loan $loan, string $dueDate, float $amount, ): self { return new self( loan: $loan, status: InstallmentStatus::DUE, dueDate: new DateTimeImmutable($dueDate), amount: $amount, ); } }

Slide 78

Slide 78 text

Modèle Métier class Installment { private function __construct( private readonly Loan $loan, private readonly InstallmentStatus $status, private readonly DateTimeImmutable $dueDate, private readonly float $amount, ) { } }

Slide 79

Slide 79 text

Modèle Métier // Customer has subscribed to a loan $loan = new Loan(...); $amount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = Installment::due($loan, '2024-10-05', $amount);

Slide 80

Slide 80 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $amount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = Installment::due($loan, '2024-10-05', $amount); // Each day, a cronjob runs to update the status of the due/overdue installments $receivedPaymentDate = ...; if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->setPaymentDate($receivedPaymentDate); $installment->setStatus(InstallmentStatus::PAID); } // If payment is still unpaid after due date, its marked overdue $today = new DateTimeImmutable('today'); if ($installment->getStatus() === InstallmentStatus::DUE && $today > $installment->getDueDate()) { $installment->setStatus(InstallmentStatus::OVERDUE); }

Slide 81

Slide 81 text

Modèle Anémique // Each day, a cronjob runs to update the status… $receivedPaymentDate = ...; if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->setPaymentDate($receivedPaymentDate); $installment->setStatus(InstallmentStatus::PAID); }

Slide 82

Slide 82 text

Modèle Métier class Installment { // ... public function isPaid(): bool { return $this->status === InstallmentStatus::PAID; } public function pay(DateTimeImmutable $paymentDate): void { if ($this->isPaid()) { throw new DomainException('Already paid!'); } $this->status = InstallmentStatus::PAID; $this->paymentDate = $paymentDate; } }

Slide 83

Slide 83 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $amount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = Installment::due($loan, '2024-10-05', $amount); // Each day, a cronjob runs to update the status // of the due/overdue installments $receivedPaymentDate = new DateTimeImmutable('2024-10-03'); if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->pay($receivedPaymentDate); } // If payment is still unpaid after due date, its marked overdue $today = new DateTimeImmutable('today'); if ($installment->getStatus() === InstallmentStatus::DUE && $today > $installment->getDueDate()) { $installment->setStatus(InstallmentStatus::OVERDUE); }

Slide 84

Slide 84 text

Modèle Métier // Each day, a cronjob runs to update the status // of the due/overdue installments $receivedPaymentDate = new DateTimeImmutable('2024-10-03'); try { if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->pay($receivedPaymentDate); } } catch (DomainException $e) { $this->logger->warning($e->getMessage()); }

Slide 85

Slide 85 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $amount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = Installment::due($loan, '2024-10-05', $amount); // Each day, a cronjob runs to update the status // of the due/overdue installments $receivedPaymentDate = new DateTimeImmutable('2024-10-03'); if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->pay($receivedPaymentDate); } // If payment is still unpaid after due date, its marked overdue $today = new DateTimeImmutable('today'); if ($installment->getStatus() === InstallmentStatus::DUE && $today > $installment->getDueDate()) { $installment->setStatus(InstallmentStatus::OVERDUE); }

Slide 86

Slide 86 text

Modèle Anémique // If payment is still unpaid after due date, its marked overdue $today = new DateTimeImmutable(‘today'); if ($installment->getStatus() === InstallmentStatus::DUE && $today > $installment->getDueDate()) { $installment->setStatus(InstallmentStatus::OVERDUE); }

Slide 87

Slide 87 text

Modèle Métier class Installment { // ... public function isDue(): bool { return $this->status === InstallmentStatus::DUE; } public function isOverdue(): bool { return $this->status === InstallmentStatus::OVERDUE; } public function overdue(): void { if (!$this->isDue()) { throw new DomainException('Installment must be due!'); } $this->status = InstallmentStatus::OVERDUE; } }

Slide 88

Slide 88 text

Modèle Anémique // Customer has subscribed to a loan $loan = new Loan(...); $amount = $this->loanService->getInstallmentAmount($loan); // On loan settlement, all its installments are calculated $installment = Installment::due($loan, '2024-10-05', $amount); // Each day, a cronjob runs to update the status // of the due/overdue installments $receivedPaymentDate = new DateTimeImmutable('2024-10-03'); if ($receivedPaymentDate instanceof DateTimeImmutable) { $installment->pay($receivedPaymentDate); } // If payment is still unpaid after due date, // it is marked overdue $today = new DateTimeImmutable('today'); if ($installment->isDue() && $today > $installment->getDueDate()) { $installment->overdue(); }

Slide 89

Slide 89 text

Modèle Métier // If payment is still unpaid after due date, // it is marked overdue $today = new DateTimeImmutable('today'); try { if ($installment->isDue() && $today > $installment->getDueDate()) { $installment->overdue(); } } catch (DomainException $e) { $this->logger->warning($e->getMessage()); }

Slide 90

Slide 90 text

Modèle Métier class Installment { // ... public function wasPaidOnTime(): bool { $this->ensureIsPaid(); return $this->getPaymentDate() <= $this->getDueDate(); } public function wasPaidLate(): bool { $this->ensureIsPaid(); return $this->getPaymentDate() > $this->getDueDate(); } private function ensureIsPaid(): void { if (!$this->isPaid()) { throw new DomainException('Installment not paid yet!'); } } }

Slide 91

Slide 91 text

Profiter des Enums https://unsplash.com/@glenncarstenspeters Encapsuler des listes prédéfinies de valeurs au même endroit.

Slide 92

Slide 92 text

Enumérations enum InstallmentStatus: string { case DUE = 'due'; case OVERDUE = 'overdue'; case PAID = 'paid'; public function isPaid(): bool { return self::PAID->value === $this->value; } public function getLabel(): string { return match ($this) { self::DUE => 'Waiting for payment', self::OVERDUE => 'Payment delay exceeded', self::PAID => 'Payment received', }; } }

Slide 93

Slide 93 text

Enumérations class Installment { // ... public function isPaid(): bool { return $this->status->isPaid(); } }

Slide 94

Slide 94 text

Inversion des dépendances https://unsplash.com/@cmycatistufa200gsugden Réduire le couplage entre les modules de haut niveau et de bas niveau.

Slide 95

Slide 95 text

Les modules de haut niveau ne doivent pas dépendre des modules de niveau inférieur. Les abstractions ne doivent pas dépendre des détails d’implémentation ; les implémentations dépendent des abstractions. Inversion des dépendances

Slide 96

Slide 96 text

Inversion des dépendances // ... use App\FinancialServices\Repository\DoctrineInstallmentRepository; use Doctrine\ORM\EntityManager; class ComputeYearlyLoanInstallmentReporting { private DoctrineInstallmentRepository $installmentRepository; public function __construct(EntityManager $entityManager) { /** @var DoctrineInstallmentRepository $installmentRepository */ $installmentRepository = $entityManager->getRepository(Installment::class); $this->installmentRepository = $installmentRepository; } // ... } detail detail detail

Slide 97

Slide 97 text

Fort couplage de la classe à la couche de persistence (ORM) Testabilité du code plus difficile Problématiques Impossibilité d’adapter facilement la couche de persistence / d’accès aux données

Slide 98

Slide 98 text

Inversion des dépendances namespace App\FinancialServices\Repository; use App\FinancialServices\Entity\Installment; use App\FinancialServices\Entity\Loan; interface InstallmentRepository { public function byId(string $id): Installement; /** * @return Installment[] */ public function findByLoan(Loan $loan): array; /** * @return Installment[] */ public function findYearlyPaid(string $year): array; public function save(Installment $installment): void; }

Slide 99

Slide 99 text

Inversion des dépendances // ... use App\FinancialServices\Repository\InstallmentRepository; class ComputeYearlyLoanInstallmentReporting { public function __construct( private readonly InstallmentRepository $installmentRepository, ) { } // ... } detail abstraction

Slide 100

Slide 100 text

Faible couplage de la classe à la couche de persistence Testabilité du code aisée Bénéfices Capacité d’adapter facilement la couche de persistence / d’accès aux données

Slide 101

Slide 101 text

Inversion des dépendances final class InMemoryInstallmentRepository implements InstallmentRepository { /** @var array */ private array $store = []; public function byId(string $id): Installement { $installment = $this->store[$id] ?? null; if (!$installment instanceof Installment) { throw new DomainException('Installment not found!'); } return $installment; } public function save(Installment $installment): void { $this->store[(string) $installment->getId()] = $installment; } } detail abstraction

Slide 102

Slide 102 text

Inversion des dépendances final class InMemoryInstallmentRepository implements InstallmentRepository { // ... public function findByLoan(Loan $loan): array { return \array_values(\array_filter( $this->store, static fn (Installment $installment): bool => $installment->getLoan()->equals($loan), )); } public function findYearlyPaid(string $year): array { return \array_values(\array_filter( $this->store, static fn (Installment $installment): bool => $installment->getDueDate()->format('Y') === $year && $installment->isPaid(), )); } }

Slide 103

Slide 103 text

Inversion des dépendances final class DoctrineInstallmentRepository implements InstallmentRepository { public function __construct( private readonly EntityManagerInterface $entityManager, ) { } public function byId(string $id): Installement { $installment = $this->getRepository()->find($id); if (!$installment instanceof Installment) { throw new DomainException('Installment not found!'); } return $installment; } public function save(Installment $installment): void { $this->entityManager->persist($installment); $this->entityManager->flush(); } private function getRepository(): EntityRepository { return $this->entityManager->getRepository(Installment::class); } } detail abstraction abstraction

Slide 104

Slide 104 text

Inversion des dépendances final class DoctrineInstallmentRepository implements InstallmentRepository { // ... public function findByLoan(Loan $loan): array { return $this->getRepository()->findBy(['loan' => $loan]); } public function findYearlyPaid(string $year): array { $qb = $this->getRepository()->createQueryBuilder('i'); $qb ->andWhere($qb->expr()->between('i.paymentDate', ':from', ':to')) ->andWhere($qb->expr()->eq('i.status', ':status')) ->setParameter('from', $year . '-01-01') ->setParameter('to', $year . '-12-31') ->setParameter('status', InstallmentStatus::PAID); /** @var Installment[] */ return $qb->getQuery()->getResult(); } }

Slide 105

Slide 105 text

Inversion des dépendances // Unit testing case $computer = new ComputeYearlyLoanInstallmentReporting( new InMemoryInstallmentRepository(), ); // General use case $computer = new ComputeYearlyLoanInstallmentReporting( new DoctrineInstallmentRepository( new EntityManager(...) ), );

Slide 106

Slide 106 text

Bibliothèques de code de la communauté PHP https://unsplash.com/@tomas_yates Pour ne pas réinventer la roue et s’appuyer sur du code de qualité.

Slide 107

Slide 107 text

Bibliothèques Tierces ★ beberlei/assert ★ league/csv ★ league/commonmark ★ league/flysystem ★ moneyphp/money ★ monolog/monolog ★ nesbot/carbon ★ symfony/clock ★ symfony/console ★ symfony/event-dispatcher ★ symfony/http-client ★ symfony/string ★ …

Slide 108

Slide 108 text

Frameworks

Slide 109

Slide 109 text

@hhamon / [email protected] Merci beaucoup !