Add PHPStan

This commit is contained in:
Hugo Alliaume
2026-01-20 23:08:29 +01:00
parent 7126e70a0d
commit 1d73c40173
96 changed files with 881 additions and 166 deletions

31
.github/workflows/phpstan.yaml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: PHPStan
on:
pull_request:
push:
branches:
- main
jobs:
phpstan:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
- name: Install dependencies
uses: ramsey/composer-install@v3
- name: Warmup Symfony cache for PHPStan
run: php bin/console cache:warmup
env:
APP_ENV: test
- name: Run PHPStan
run: vendor/bin/phpstan analyse

View File

@@ -1,5 +1,11 @@
name: Tests
on:
pull_request:
push:
branches:
- main
jobs:
tests:
runs-on: ubuntu-latest
@@ -13,19 +19,8 @@ jobs:
with:
php-version: '8.5'
- name: Install root dependencies
uses: ramsey/composer-install@v3
with:
working-directory: ${{ github.workspace }}
- name: Install dependencies
uses: ramsey/composer-install@v3
with:
working-directory: ux.symfony.com
dependency-versions: 'highest'
- name: Importmap dependencies
run: php bin/console importmap:install
- name: Build Sass assets
run: php bin/console sass:build

4
.gitignore vendored
View File

@@ -20,3 +20,7 @@
###> vincentlanglet/twig-cs-fixer ###
/.twig-cs-fixer.cache
###< vincentlanglet/twig-cs-fixer ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

4
AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
# Development Commands
- Run PHPStan: `symfony php vendor/bin/phpstan analyse`
- Run PHPUnit: `symfony php vendor/bin/simple-phpunit`

View File

@@ -14,6 +14,7 @@
"intervention/image": "^2.7.2",
"kornrunner/blurhash": "^1.2.2",
"league/commonmark": "^2.6.0",
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*",
"symfony/asset-mapper": "8.0.*",
"symfony/console": "8.0.*",
@@ -69,6 +70,10 @@
"twig/twig": "^3.22"
},
"require-dev": {
"kocal/phpstan-symfony-ux": "^1.1",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.6.21",
"symfony/browser-kit": "8.0.*",
"symfony/css-selector": "8.0.*",

303
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "311ac68b2d9eb622b96fa52b63f36e36",
"content-hash": "5ef3f75f2ff5431ddddb3a76deb7da4f",
"packages": [
{
"name": "composer/semver",
@@ -2038,6 +2038,53 @@
},
"time": "2025-12-22T12:14:32+00:00"
},
{
"name": "phpstan/phpdoc-parser",
"version": "2.3.1",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374",
"reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"require-dev": {
"doctrine/annotations": "^2.0",
"nikic/php-parser": "^5.3.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"symfony/process": "^5.2"
},
"type": "library",
"autoload": {
"psr-4": {
"PHPStan\\PhpDocParser\\": [
"src/"
]
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1"
},
"time": "2026-01-12T11:33:04+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
@@ -10163,6 +10210,63 @@
},
"time": "2024-11-21T13:46:39+00:00"
},
{
"name": "kocal/phpstan-symfony-ux",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/Kocal/phpstan-symfony-ux.git",
"reference": "0746480622429b8b0ee2383251585213d9a87c7d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Kocal/phpstan-symfony-ux/zipball/0746480622429b8b0ee2383251585213d9a87c7d",
"reference": "0746480622429b8b0ee2383251585213d9a87c7d",
"shasum": ""
},
"require": {
"php": ">=8.2",
"phpstan/phpstan": "^2.1.13"
},
"require-dev": {
"phpunit/phpunit": "^11.1",
"symfony/ux-live-component": "^2.0",
"symfony/ux-twig-component": "^2.0",
"symplify/easy-coding-standard": "^13.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"files": [
"src/deprecated-aliases.php"
],
"psr-4": {
"Kocal\\PHPStanSymfonyUX\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Hugo Alliaume",
"email": "hugo@alliau.me"
}
],
"description": "PHPStan rules for Symfony UX",
"support": {
"issues": "https://github.com/Kocal/phpstan-symfony-ux/issues",
"source": "https://github.com/Kocal/phpstan-symfony-ux/tree/v1.1.0"
},
"time": "2025-12-16T00:28:25+00:00"
},
{
"name": "myclabs/deep-copy",
"version": "1.13.4",
@@ -10399,6 +10503,203 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.1.35",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/72f843c7f59d3aac0b7510f5e70913a9b72a8e88",
"reference": "72f843c7f59d3aac0b7510f5e70913a9b72a8e88",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-01-20T17:33:48+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
"version": "2.0.13",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
"reference": "2d2ad04a0ac14ac52e21ad47ec67a54a14355c1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/2d2ad04a0ac14ac52e21ad47ec67a54a14355c1f",
"reference": "2d2ad04a0ac14ac52e21ad47ec67a54a14355c1f",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.34"
},
"conflict": {
"doctrine/collections": "<1.0",
"doctrine/common": "<2.7",
"doctrine/mongodb-odm": "<1.2",
"doctrine/orm": "<2.5",
"doctrine/persistence": "<1.3"
},
"require-dev": {
"cache/array-adapter": "^1.1",
"composer/semver": "^3.3.2",
"cweagans/composer-patches": "^1.7.3",
"doctrine/annotations": "^2.0",
"doctrine/collections": "^1.6 || ^2.1",
"doctrine/common": "^2.7 || ^3.0",
"doctrine/dbal": "^3.3.8",
"doctrine/lexer": "^2.0 || ^3.0",
"doctrine/mongodb-odm": "^2.4.3",
"doctrine/orm": "^2.16.0",
"doctrine/persistence": "^2.2.1 || ^3.2",
"gedmo/doctrine-extensions": "^3.8",
"nesbot/carbon": "^2.49",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-deprecation-rules": "^2.0.2",
"phpstan/phpstan-phpunit": "^2.0.8",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6.20",
"ramsey/uuid": "^4.2",
"symfony/cache": "^5.4",
"symfony/uid": "^5.4 || ^6.4 || ^7.3"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Doctrine extensions for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
"source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.13"
},
"time": "2026-01-18T16:15:40+00:00"
},
{
"name": "phpstan/phpstan-symfony",
"version": "2.0.10",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "5a7ab5319a0b0d856ddbe08f67a21b00b386107f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/5a7ab5319a0b0d856ddbe08f67a21b00b386107f",
"reference": "5a7ab5319a0b0d856ddbe08f67a21b00b386107f",
"shasum": ""
},
"require": {
"ext-simplexml": "*",
"php": "^7.4 || ^8.0",
"phpstan/phpstan": "^2.1.13"
},
"conflict": {
"symfony/framework-bundle": "<3.0"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^2.0.8",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^9.6",
"psr/container": "1.1.2",
"symfony/config": "^5.4 || ^6.1",
"symfony/console": "^5.4 || ^6.1",
"symfony/dependency-injection": "^5.4 || ^6.1",
"symfony/form": "^5.4 || ^6.1",
"symfony/framework-bundle": "^5.4 || ^6.1",
"symfony/http-foundation": "^5.4 || ^6.1",
"symfony/messenger": "^5.4",
"symfony/polyfill-php80": "^1.24",
"symfony/serializer": "^5.4",
"symfony/service-contracts": "^2.2.0"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon",
"rules.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Lukáš Unger",
"email": "looky.msc@gmail.com",
"homepage": "https://lookyman.net"
}
],
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.10"
},
"time": "2026-01-20T16:40:28+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "9.2.32",

42
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,42 @@
includes:
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/kocal/phpstan-symfony-ux/extension.neon
- tools/phpstan/symfony-configuration.php
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/
excludePaths:
- config/reference.php
doctrine:
objectManagerLoader: tools/phpstan/object-manager.php
ignoreErrors:
- message: '#Method .+::test.+\(\) has no return type specified\.#'
paths:
- tests/
rules:
# LiveComponent rules
- Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LiveActionMethodsVisibilityRule
- Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LiveListenerMethodsVisibilityRule
- Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropHydrationMethodsRule
- Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule
# TwigComponent rules
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassMustBeFinalRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassNameMustNotEndWithComponentRule
# ExposePublicPropsMustBeFalseRule is NOT enabled as requested
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenAttributesPropertyRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenClassPropertyRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\MethodsVisibilityRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\PostMountMethodSignatureRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\PreMountMethodSignatureRule
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\PublicPropertiesMustBeCamelCaseRule

View File

@@ -76,6 +76,9 @@ final class SitemapController extends AbstractController
}
}
/**
* @param array<string, mixed> $parameters
*/
private function generateAbsoluteUrl(string $route, array $parameters = []): string
{
return $this->generateUrl($route, $parameters, UrlGeneratorInterface::ABSOLUTE_URL);

View File

@@ -57,10 +57,13 @@ class AutocompleteController extends AbstractController
return $words[array_rand($words)];
}
/**
* @param Collection<int, Food> $foods
*/
private function generateEatingMessage(Collection $foods, string $name): string
{
$i = 0;
$foodStrings = $foods->map(function (Food $food) use (&$i, $foods) {
$foodStrings = $foods->map(static function (Food $food) use (&$i, $foods) {
++$i;
$str = $food->getName();

View File

@@ -37,11 +37,13 @@ class LiveComponentController extends AbstractController
return $this->redirectToRoute('app_demos');
}
// Permanent Redirect old URL
if (null !== $liveDemo = $liveDemoRepository->find($demo)) {
return $this->redirectToRoute($liveDemo->getRoute(), [], 301);
}
try {
// Permanent Redirect old URL
$liveDemo = $liveDemoRepository->find($demo);
throw $this->createNotFoundException(\sprintf('Live Component demo "%s" not found.', $demo));
return $this->redirectToRoute($liveDemo->getRoute(), [], 301);
} catch (\InvalidArgumentException $e) {
throw $this->createNotFoundException(\sprintf('Live Component demo "%s" not found.', $demo), previous: $e);
}
}
}

View File

@@ -148,6 +148,7 @@ class TurboController extends AbstractController
}
}
/** @var array<string> */
private static array $messages = [
'Hey!',
'Hola!',

View File

@@ -38,6 +38,9 @@ class Invoice
#[Assert\Range(min: 0, max: 100)]
private int $taxRate = 0;
/**
* @var Collection<int, InvoiceItem>
*/
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: InvoiceItem::class, orphanRemoval: true)]
private Collection $invoiceItems;

View File

@@ -30,6 +30,9 @@ class TodoList
#[NotBlank]
private ?string $name = null;
/**
* @var Collection<int, TodoItem>
*/
#[ORM\OneToMany(mappedBy: 'todoList', targetEntity: TodoItem::class, orphanRemoval: true, cascade: ['persist'])]
#[Valid]
private Collection $todoItems;

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array{item?: string}>
*/
class AddTodoItemForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -17,6 +17,9 @@ use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array{name?: string, animal?: string}>
*/
class AnimalCreationForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -13,8 +13,12 @@ namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\UX\Dropzone\Form\DropzoneType;
/**
* @extends AbstractType<array{file?: File}>
*/
class DropzoneForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -12,12 +12,16 @@
namespace App\Form;
use App\Entity\Food;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
/**
* @extends AbstractType<Collection<int, Food>>
*/
#[AsEntityAutocompleteField]
class FoodAutocompleteField extends AbstractType
{

View File

@@ -22,6 +22,9 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfonycasts\DynamicForms\DependentField;
use Symfonycasts\DynamicForms\DynamicFormBuilder;
/**
* @extends AbstractType<MealPlan>
*/
class MealPlannerForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -36,22 +39,22 @@ class MealPlannerForm extends AbstractType
$builder
->add('meal', EnumType::class, [
'class' => Meal::class,
'choice_label' => fn (Meal $meal): string => $meal->getReadable(),
'choice_label' => static fn (Meal $meal): string => $meal->getReadable(),
'placeholder' => 'Which meal is it?',
'autocomplete' => true,
])
// see: https://github.com/SymfonyCasts/dynamic-forms
->addDependent('mainFood', 'meal', function (DependentField $field, ?Meal $meal) {
->addDependent('mainFood', 'meal', static function (DependentField $field, ?Meal $meal) {
$field->add(EnumType::class, [
'class' => Food::class,
'placeholder' => null === $meal ? 'Select a meal first' : \sprintf('What\'s for %s?', $meal->getReadable()),
'choices' => $meal?->getFoodChoices(),
'choice_label' => fn (Food $food): string => $food->getReadable(),
'choice_label' => static fn (Food $food): string => $food->getReadable(),
'disabled' => null === $meal,
'autocomplete' => true,
]);
})
->addDependent('pizzaSize', 'mainFood', function (DependentField $field, ?Food $food) {
->addDependent('pizzaSize', 'mainFood', static function (DependentField $field, ?Food $food) {
if (Food::Pizza !== $food) {
return;
}
@@ -59,7 +62,7 @@ class MealPlannerForm extends AbstractType
$field->add(EnumType::class, [
'class' => PizzaSize::class,
'placeholder' => 'What size pizza?',
'choice_label' => fn (PizzaSize $pizzaSize): string => $pizzaSize->getReadable(),
'choice_label' => static fn (PizzaSize $pizzaSize): string => $pizzaSize->getReadable(),
'required' => true,
'autocomplete' => true,
]);

View File

@@ -20,6 +20,9 @@ use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<null>
*/
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
/**
* @extends AbstractType<array<string, mixed>>
*/
class SendNotificationForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -33,6 +36,9 @@ class SendNotificationForm extends AbstractType
]);
}
/**
* @return array<int, string>
*/
public static function getTextChoices(): array
{
return [

View File

@@ -15,6 +15,9 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<string, mixed>>
*/
class TimeForAMealForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<TodoItem>
*/
class TodoItemForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -17,6 +17,9 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType;
/**
* @extends AbstractType<TodoList>
*/
class TodoListFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -16,6 +16,9 @@ use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<string, mixed>>
*/
class TogglePasswordForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void

View File

@@ -36,7 +36,7 @@ use Symfony\UX\TwigComponent\Attribute\PostMount;
name: 'LiveMemory:Board',
template: 'demos/live_memory/components/LiveMemory/Board.html.twig',
)]
class Board extends AbstractController
final class Board extends AbstractController
{
use ComponentToolsTrait;
use DefaultActionTrait;
@@ -168,6 +168,9 @@ class Board extends AbstractController
}
}
/**
* @return array<int, array<string, bool|string>>
*/
#[ExposeInTemplate('cards')]
public function getCards(): array
{

View File

@@ -26,7 +26,7 @@ use function Symfony\Component\String\u;
name: 'LiveMemory:Icon',
template: 'demos/live_memory/components/LiveMemory/Icon.html.twig',
)]
class Icon
final class Icon
{
/**
* Name of the icon file without extension (ex: `symfony-ux`).
@@ -38,7 +38,7 @@ class Icon
*/
public ?string $label = null;
protected string $iconDirectory;
private string $iconDirectory;
public function __construct(
#[Autowire('%kernel.project_dir%')] string $projectDir,

View File

@@ -23,7 +23,7 @@ use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
name: 'LiveMemory:ScoreRow',
template: 'demos/live_memory/components/LiveMemory/ScoreRow.html.twig',
)]
class ScoreRow
final class ScoreRow
{
public string $label;

View File

@@ -28,7 +28,7 @@ use Symfony\UX\TwigComponent\Attribute\PostMount;
name: 'LiveMemory:Timer',
template: 'demos/live_memory/components/LiveMemory/Timer.html.twig',
)]
class Timer
final class Timer
{
use ComponentToolsTrait;
use DefaultActionTrait;
@@ -101,17 +101,17 @@ class Timer
return $remainingTime <= ($this->warningThreshold * 1000);
}
public function hydrateDate(?string $startedAt): ?\DateTimeImmutable
public function hydrateDate(string $startedAt): \DateTimeImmutable
{
if (null === $startedAt || '' === $startedAt) {
return null;
if ('' === $startedAt) {
return new \DateTimeImmutable();
}
return \DateTimeImmutable::createFromFormat('U', $startedAt);
}
public function dehydrateDate(?\DateTimeImmutable $startedAt): ?string
public function dehydrateDate(\DateTimeImmutable $startedAt): string
{
return (string) $startedAt?->format('U');
return (string) $startedAt->format('U');
}
}

View File

@@ -32,7 +32,7 @@ class Game
/**
* Map of game card as <cardKey> => <cardValue>.
*
* @var list<int, string>
* @var list<string>
*/
private readonly array $cards;
@@ -101,6 +101,9 @@ class Game
return \count($this->cards);
}
/**
* @return array{int, int}
*/
public function getGrid(): array
{
$cardCount = $this->getCardCount();
@@ -111,6 +114,9 @@ class Game
];
}
/**
* @return list<int>
*/
public function getFlips(): array
{
return $this->flips;
@@ -129,6 +135,9 @@ class Game
$this->flips[] = $key;
}
/**
* @return array<int>
*/
public function getMatches(): array
{
return $this->matches;
@@ -150,6 +159,9 @@ class Game
$this->matches[] = $key;
}
/**
* @return list<int>
*/
public function getCurrentPair(): array
{
$selectedPairs = $this->getSelectedPairs();
@@ -157,6 +169,9 @@ class Game
return array_pop($selectedPairs) ?? [];
}
/**
* @return list<list<int>>
*/
public function getSelectedPairs(): array
{
return array_chunk($this->getFlips(), 2);
@@ -228,13 +243,10 @@ class Game
public function getTime(): int
{
if (null !== $start = $this->getCreatedAt()) {
$end = ($this->getEndedAt() ?? new \DateTimeImmutable('now'));
$start = $this->getCreatedAt();
$end = ($this->getEndedAt() ?? new \DateTimeImmutable('now'));
return $end->getTimestamp() - $start->getTimestamp();
}
return 5;
return $end->getTimestamp() - $start->getTimestamp();
}
public function getCreatedAt(): \DateTimeImmutable

View File

@@ -23,7 +23,7 @@ final class GameCards
*/
public static function getCards(): array
{
return array_map(fn ($i) => \sprintf('%02d', $i), range(1, 16));
return array_map(static fn ($i) => \sprintf('%02d', $i), range(1, 16));
}
/**
@@ -35,7 +35,7 @@ final class GameCards
$cards = [...$cards, ...$cards];
shuffle($cards);
return array_values($cards);
return $cards;
}
/**

View File

@@ -24,7 +24,7 @@ final class GameLevels
* nbCards = (level + 2) * 2
* timeLimit = level * 20
*
* @var array<int, array{nbCards: int, theme: string, timeLimit: int, grid: string}>
* @var array<int, array{0: int, 1: string, 2: int, 3: string}>
*/
private const LEVEL_METADATA = [
1 => [6, 'blue', 20, '3x2'],

View File

@@ -13,6 +13,9 @@ namespace App\Model;
class Demo
{
/**
* @param list<string> $tags
*/
public function __construct(
private string $identifier,
private string $name,

View File

@@ -29,6 +29,15 @@ class IconSet
public const CATEGORY_ARCHIVE_UNMAINTAINED = 'Archive / Unmaintained';
public const CATEGORY_UNCATEGORIZED = 'Uncategorized';
/**
* @param array<string, mixed> $author
* @param array<string, mixed> $license
* @param list<string>|null $samples
* @param array<string, int>|int|null $height
* @param list<string>|null $tags
* @param array<string, string>|null $suffixes
* @param array<string, mixed>|null $categories
*/
public function __construct(
private string $identifier,
private string $name,
@@ -63,11 +72,17 @@ class IconSet
return $this->name;
}
/**
* @return array<string, mixed>
*/
public function getAuthor(): array
{
return $this->author;
}
/**
* @return array<string, mixed>
*/
public function getLicense(): array
{
return $this->license;
@@ -83,11 +98,17 @@ class IconSet
return $this->version;
}
public function getSamples(): array
/**
* @return list<string>|null
*/
public function getSamples(): ?array
{
return $this->samples;
}
/**
* @return array<string, int>|int|null
*/
public function getHeight(): array|int|null
{
return $this->height;
@@ -103,6 +124,9 @@ class IconSet
return $this->category;
}
/**
* @return list<string>|null
*/
public function getTags(): ?array
{
return $this->tags;
@@ -113,11 +137,17 @@ class IconSet
return $this->palette;
}
/**
* @return array<string, string>|null
*/
public function getSuffixes(): ?array
{
return $this->suffixes;
}
/**
* @return array<string, mixed>|null
*/
public function getCategories(): ?array
{
return $this->categories;
@@ -133,6 +163,9 @@ class IconSet
return $this->isFavorite ?? false;
}
/**
* @return array<string, string>|null
*/
public function getGithub(): ?array
{
$urls = [

View File

@@ -13,6 +13,9 @@ namespace App\Model;
final class LiveDemo extends Demo
{
/**
* @param list<string> $tags
*/
public function __construct(
string $identifier,
string $name,

View File

@@ -13,6 +13,9 @@ namespace App\Model;
class RecipeFileTree
{
/**
* @var array<string, array<string, mixed>>
*/
private array $files = [];
public function __construct()
@@ -45,11 +48,17 @@ class RecipeFileTree
return $this;
}
/**
* @return array<string, array<string, mixed>>
*/
public function getFiles(): array
{
return $this->buildFileTree('');
}
/**
* @return array<string, array<string, mixed>>
*/
public function buildFileTree(string $targetDirectory): array
{
$files = [];

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Category>
*
* @method Category|null find($id, $lockMode = null, $lockVersion = null)
* @method Category|null findOneBy(array $criteria, array $orderBy = null)
* @method Category[] findAll()
* @method Category[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class CategoryRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Chat>
*
* @method Chat|null find($id, $lockMode = null, $lockVersion = null)
* @method Chat|null findOneBy(array $criteria, array $orderBy = null)
* @method Chat[] findAll()
* @method Chat[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ChatRepository extends ServiceEntityRepository
{

View File

@@ -19,11 +19,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Food>
*
* @method Food|null find($id, $lockMode = null, $lockVersion = null)
* @method Food|null findOneBy(array $criteria, array $orderBy = null)
* @method Food[] findAll()
* @method Food[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class FoodRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<InvoiceItem>
*
* @method InvoiceItem|null find($id, $lockMode = null, $lockVersion = null)
* @method InvoiceItem|null findOneBy(array $criteria, array $orderBy = null)
* @method InvoiceItem[] findAll()
* @method InvoiceItem[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class InvoiceItemRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Invoice>
*
* @method Invoice|null find($id, $lockMode = null, $lockVersion = null)
* @method Invoice|null findOneBy(array $criteria, array $orderBy = null)
* @method Invoice[] findAll()
* @method Invoice[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class InvoiceRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Product>
*
* @method Product|null find($id, $lockMode = null, $lockVersion = null)
* @method Product|null findOneBy(array $criteria, array $orderBy = null)
* @method Product[] findAll()
* @method Product[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class ProductRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TodoItem>
*
* @method TodoItem|null find($id, $lockMode = null, $lockVersion = null)
* @method TodoItem|null findOneBy(array $criteria, array $orderBy = null)
* @method TodoItem[] findAll()
* @method TodoItem[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TodoItemRepository extends ServiceEntityRepository
{

View File

@@ -17,11 +17,6 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TodoList>
*
* @method TodoList|null find($id, $lockMode = null, $lockVersion = null)
* @method TodoList|null findOneBy(array $criteria, array $orderBy = null)
* @method TodoList[] findAll()
* @method TodoList[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class TodoListRepository extends ServiceEntityRepository
{

View File

@@ -26,6 +26,9 @@ final class ChangelogProvider
) {
}
/**
* @return array<int, array{id: int, name: string, version: string, date: string, body: string}>
*/
public function getChangelog(int $page = 1): array
{
$changelog = [];
@@ -37,6 +40,9 @@ final class ChangelogProvider
return $changelog;
}
/**
* @return array<int, array{id: int, name: string, version: string, date: string, body: string}>
*/
private function getReleases(int $page = 1): array
{
return $this->cache->get('releases-symfony-ux-'.$page, function (CacheItemInterface $item) use ($page) {

View File

@@ -17,15 +17,15 @@ use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use Symfony\UX\TwigComponent\ComponentRendererInterface;
final readonly class FencedCodeRenderer implements NodeRendererInterface
final class FencedCodeRenderer implements NodeRendererInterface
{
public function __construct(
private ComponentRendererInterface $componentRenderer,
private readonly ComponentRendererInterface $componentRenderer,
) {
}
#[\Override]
public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable|string|null
public function render(Node $node, ChildNodeRendererInterface $childRenderer): string
{
if (!$node instanceof FencedCode) {
throw new \InvalidArgumentException('Block must be instance of '.FencedCode::class);

View File

@@ -24,7 +24,6 @@ use League\CommonMark\Parser\MarkdownParserStateInterface;
final class TabParser extends AbstractBlockContinueParser
{
private Tab $block;
private bool $finished = false;
public function __construct(string $title)
{

View File

@@ -73,7 +73,7 @@ final class ToolkitPreviewParser extends AbstractBlockContinueParser
return false;
}
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): ?BlockContinue
public function tryContinue(Cursor $cursor, BlockContinueParserInterface $activeBlockParser): BlockContinue
{
if ($cursor->isBlank()) {
return BlockContinue::at($cursor);

View File

@@ -13,6 +13,9 @@ namespace App\Service;
class DinoStatsService
{
/**
* @var array<string, mixed>|null
*/
private ?array $rawData = null;
private const ALL_DINOS = 'all';
@@ -32,6 +35,9 @@ class DinoStatsService
'de3267',
];
/**
* @return list<string>
*/
public static function getAllTypes(): array
{
return [
@@ -45,13 +51,18 @@ class DinoStatsService
];
}
/**
* @param list<string> $types
*
* @return array{labels: list<string>, datasets: list<array<string, mixed>>}
*/
public function fetchData(int $start, int $end, array $types): array
{
$start = abs($start);
$end = abs($end);
$steps = 10;
$step = round(($start - $end) / $steps);
$step = (int) round(($start - $end) / $steps);
$labels = [];
for ($i = 0; $i < $steps; ++$i) {
@@ -83,6 +94,9 @@ class DinoStatsService
];
}
/**
* @return array<string, mixed>
*/
private function getRawData(): array
{
if (null === $this->rawData) {
@@ -92,11 +106,14 @@ class DinoStatsService
return $this->rawData;
}
/**
* @return list<int>
*/
private function getSpeciesCounts(int $start, int $steps, int $step, string $type): array
{
$counts = [];
for ($i = 0; $i < $steps; ++$i) {
$current = round($start - ($i * $step));
$current = (int) round($start - ($i * $step));
$counts[] = $this->countSpeciesAt($current, $type);
}

View File

@@ -20,8 +20,14 @@ namespace App\Service;
*/
final class EmojiCollection implements \IteratorAggregate, \Countable
{
/**
* @var list<string>
*/
private array $emojis;
/**
* @param list<string> $emojis
*/
public function __construct(array $emojis = [])
{
$this->emojis = $emojis ?: $this->loadEmojis();
@@ -42,6 +48,9 @@ final class EmojiCollection implements \IteratorAggregate, \Countable
return \count($this->emojis);
}
/**
* @return list<string>
*/
private function loadEmojis(): array
{
return [

View File

@@ -28,12 +28,11 @@ class IconSetRepository
'bootstrap',
];
/**
* @var array<string, IconSet>
*/
private array $iconSets;
private array $terms = [
'crypto',
];
public function __construct(
private Iconify $iconify,
) {
@@ -77,6 +76,9 @@ class IconSetRepository
return null;
}
/**
* @param array<string, mixed> $data
*/
private static function createIconSet(string $identifier, array $data): IconSet
{
return new IconSet(

View File

@@ -26,6 +26,9 @@ final class Iconify
) {
}
/**
* @return array<string, mixed>
*/
public function search(string $query, ?string $prefix = null, int $limit = 32, ?int $start = null): array
{
return $this->http
@@ -41,11 +44,17 @@ final class Iconify
;
}
public function collection(string $name): ?array
/**
* @return array<string, mixed>
*/
public function collection(string $name): array
{
return $this->collectionData($name);
}
/**
* @return array<string, mixed>
*/
public function collectionStyles(string $prefix): array
{
$data = $this->collectionData($prefix);
@@ -56,6 +65,9 @@ final class Iconify
return $styles;
}
/**
* @return array<string, mixed>
*/
public function collectionCategories(string $prefix): array
{
$data = $this->collectionData($prefix);
@@ -74,6 +86,9 @@ final class Iconify
return $categories;
}
/**
* @return array<string, mixed>
*/
private function collectionData(string $prefix): array
{
return $this->cache->get('iconify-collection-'.$prefix, function (ItemInterface $item) use ($prefix) {
@@ -87,6 +102,9 @@ final class Iconify
});
}
/**
* @return list<string>
*/
public function collectionIcons(string $prefix): array
{
$icons = [];
@@ -105,6 +123,9 @@ final class Iconify
return array_keys($icons);
}
/**
* @return array<string, mixed>
*/
public function collections(): array
{
return $this->cache->get('iconify-collections', function (ItemInterface $item) {

View File

@@ -29,7 +29,7 @@ class LiveDemoRepository
publishedAt: '2024-06-07',
tags: ['grid', 'pagination', 'loading', 'scroll'],
longDescription: <<<EOF
The second and final part of the **Infinite Scroll Serie**, with a new range of (lovely) T-Shirts!
The second and final part of the **Infinite Scroll Series**, with a new range of (lovely) T-Shirts!
Now with `automatic loading on scroll`, a new trick and amazing `loading animations`!
EOF,
),
@@ -201,6 +201,9 @@ class LiveDemoRepository
return current($demos);
}
/**
* @throws \InvalidArgumentException if the demo is not found
*/
public function find(string $identifier): LiveDemo
{
$demos = $this->findAll();

View File

@@ -23,6 +23,9 @@ class ShellLanguage extends BaseLanguage
return 'shell';
}
/**
* @return list<string>
*/
public function getAliases(): array
{
return ['bash', 'sh'];

View File

@@ -68,10 +68,7 @@ class ToolkitService
}
/**
* @return array<string, array{
* props: array<array{name: string, type: string, description: string}>,
* blocks: array<array{name: string, description: string}>
* }
* @return array<string, array{props: list<array{name: string, type: string, description: string}>, blocks: list<array{name: string, description: string}>}>
*/
public function extractRecipeApiReference(Recipe $recipe): array
{

View File

@@ -21,6 +21,9 @@ class UxPackageDataProvider
) {
}
/**
* @return array<string, mixed>
*/
public function getPackages(): array
{
$packages = $this->packageRepository->findAll();

View File

@@ -14,7 +14,7 @@ namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Alert
final class Alert
{
public string $type = 'success';
public string $message;
@@ -24,6 +24,7 @@ class Alert
return match ($this->type) {
'success' => 'bi:check-circle',
'danger' => 'bi:exclamation-circle',
default => 'bi:info-circle',
};
}
}

View File

@@ -14,6 +14,9 @@ namespace App\Twig\Components\Badge;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
/**
* @phpstan-ignore-next-line symfonyUX.twigComponent.classMustBeFinal
*/
#[AsTwigComponent(
name: 'Badge',
template: 'components/Badge.html.twig',
@@ -29,6 +32,9 @@ class Badge
public string $url;
/**
* @return array{icon: ?string, label: string, value: string, url: ?string}
*/
#[ExposeInTemplate(destruct: true)]
public function getBadge(): array
{
@@ -40,22 +46,22 @@ class Badge
];
}
protected function getLabel(): string
private function getLabel(): string
{
return $this->label;
}
protected function getValue(): string
private function getValue(): string
{
return $this->value ?? '';
}
protected function getIcon(): ?string
private function getIcon(): string
{
return $this->icon;
}
protected function getUrl(): ?string
private function getUrl(): string
{
return $this->url ?? '';
}

View File

@@ -14,7 +14,7 @@ namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class BootstrapModal
final class BootstrapModal
{
public ?string $id = null;
}

View File

@@ -14,8 +14,11 @@ namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('ChangelogItem')]
class ChangelogItem
final class ChangelogItem
{
/**
* @var array{id: int, name?: string, version: string, date: string, body?: string}
*/
public array $item;
public bool $isOpen = false;

View File

@@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('CodeBlock', template: 'components/Code/CodeBlock.html.twig')]
class CodeBlock
final class CodeBlock
{
public string $filename;
public string $height = '300px';
@@ -176,7 +176,7 @@ class CodeBlock
};
}
public function getElementId(): ?string
public function getElementId(): string
{
return FilenameHelper::getElementId($this->filename);
}
@@ -186,6 +186,8 @@ class CodeBlock
*
* This allows us to inject some HTML (e.g. a <span> around use statements)
* that will be kept raw / not highlighted.
*
* @return list<array{content: string, highlight: bool}>
*/
private function splitAndProcessSource(string $content): array
{

View File

@@ -14,7 +14,7 @@ namespace App\Twig\Components\Code;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('CodeBlockInline', template: 'components/Code/CodeBlockInline.html.twig')]
class CodeBlockInline
final class CodeBlockInline
{
public string $code;
public string $language;

View File

@@ -14,7 +14,7 @@ namespace App\Twig\Components\Code;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('Code:CodeWithExplanationRow')]
class CodeWithExplanationRow
final class CodeWithExplanationRow
{
public string $filename;

View File

@@ -15,8 +15,11 @@ use App\Util\FilenameHelper;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('Code:TabbedCodeBlocks')]
class TabbedCodeBlocks
final class TabbedCodeBlocks
{
/**
* @var array<string, mixed>
*/
public array $files = [];
public function getItemId(string $filename): string

View File

@@ -16,7 +16,7 @@ use App\Service\LiveDemoRepository;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('Demo:PrevNext')]
class PrevNextDemo
final class PrevNextDemo
{
public function __construct(
private readonly LiveDemoRepository $demoRepository,

View File

@@ -20,10 +20,11 @@ use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsLiveComponent]
class DinoChart
final class DinoChart
{
use DefaultActionTrait;
/** @var list<string> */
#[LiveProp(writable: true)]
public array $currentTypes = ['all', 'large theropod', 'small theropod'];
@@ -85,6 +86,9 @@ class DinoChart
return $chart;
}
/**
* @return list<string>
*/
#[ExposeInTemplate]
public function allTypes(): array
{

View File

@@ -15,7 +15,7 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsTwigComponent]
class DocsLink
final class DocsLink
{
public string $size = 'md';
public string $url;

View File

@@ -21,7 +21,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class FoodVote extends AbstractController
final class FoodVote extends AbstractController
{
use DefaultActionTrait;
@@ -36,7 +36,7 @@ class FoodVote extends AbstractController
}
#[LiveAction]
public function vote(#[LiveArg] string $direction)
public function vote(#[LiveArg] string $direction): void
{
if ('up' === $direction) {
$this->food->upVote();

View File

@@ -18,17 +18,20 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsTwigComponent]
class HomepageTerminalSwapper
final class HomepageTerminalSwapper
{
public function __construct(private UxPackageRepository $packageRepository)
{
}
/**
* @return list<string>
*/
#[ExposeInTemplate]
public function getTypedStrings(): array
{
$strings = [];
$packages = array_filter($this->packageRepository->findAll(), fn (UxPackage $p): bool => null !== $p->getCreateString());
$packages = array_filter($this->packageRepository->findAll(), static fn (UxPackage $p): bool => null !== $p->getCreateString());
shuffle($packages);

View File

@@ -23,7 +23,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\Attribute\PostMount;
#[AsLiveComponent('Icon:IconModal')]
class IconModal
final class IconModal
{
use DefaultActionTrait;

View File

@@ -20,7 +20,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent('Icon:IconSearch')]
class IconSearch
final class IconSearch
{
use DefaultActionTrait;
@@ -32,6 +32,7 @@ class IconSearch
#[LiveProp(writable: true, url: true)]
public ?string $set = null;
/** @var list<Icon> */
private array $icons;
public function __construct(
@@ -40,6 +41,9 @@ class IconSearch
) {
}
/**
* @return array<string, array<string, string>>
*/
public function getIconSetOptionGroups(): array
{
$groups = [];
@@ -66,6 +70,9 @@ class IconSearch
return substr(md5(serialize([$this->query, $this->set])), 0, 8);
}
/**
* @return list<Icon>
*/
public function icons(): array
{
return $this->icons ??= $this->searchIcons();
@@ -76,6 +83,9 @@ class IconSearch
return $this->set ? $this->iconSetRepository->get($this->set) : null;
}
/**
* @return list<Icon>
*/
private function searchIcons(): array
{
if (!$this->query) {
@@ -90,6 +100,6 @@ class IconSearch
$icons = $this->iconify->search($this->query, $this->set, self::PER_PAGE)['icons'];
return array_map(fn ($icon) => Icon::fromIdentifier($icon), $icons);
return array_map(static fn ($icon) => Icon::fromIdentifier($icon), $icons);
}
}

View File

@@ -15,7 +15,7 @@ use App\Model\Icon\IconSet;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('Icon:IconSetCard')]
class IconSetCard
final class IconSetCard
{
public IconSet $iconSet;
@@ -62,6 +62,9 @@ class IconSetCard
],
];
/**
* @return list<string>
*/
public function getSampleIcons(): array
{
return self::ICONSET_SAMPLES[$this->iconSet->getIdentifier()] ?? [];

View File

@@ -22,7 +22,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
#[AsLiveComponent]
class InlineEditFood extends AbstractController
final class InlineEditFood extends AbstractController
{
use DefaultActionTrait;
use ValidatableComponentTrait;
@@ -46,13 +46,13 @@ class InlineEditFood extends AbstractController
public ?string $flashMessage = null;
#[LiveAction]
public function activateEditing()
public function activateEditing(): void
{
$this->isEditing = true;
}
#[LiveAction]
public function save(EntityManagerInterface $entityManager)
public function save(EntityManagerInterface $entityManager): void
{
// if validation fails, this throws an exception & the component re-renders
$this->validate();

View File

@@ -28,7 +28,7 @@ use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsLiveComponent]
class InvoiceCreator extends AbstractController
final class InvoiceCreator extends AbstractController
{
use DefaultActionTrait;
use ValidatableComponentTrait;
@@ -37,6 +37,9 @@ class InvoiceCreator extends AbstractController
#[Valid]
public Invoice $invoice;
/**
* @var list<array{productId: int|null, quantity: int, isEditing: bool}>
*/
#[LiveProp]
public array $lineItems = [];
@@ -77,7 +80,7 @@ class InvoiceCreator extends AbstractController
}
#[LiveListener('line_item:change_edit_mode')]
public function onLineItemEditModeChange(#[LiveArg] int $key, #[LiveArg] $isEditing): void
public function onLineItemEditModeChange(#[LiveArg] int $key, #[LiveArg] bool $isEditing): void
{
$this->lineItems[$key]['isEditing'] = $isEditing;
}
@@ -95,7 +98,7 @@ class InvoiceCreator extends AbstractController
}
#[LiveAction]
public function saveInvoice(EntityManagerInterface $entityManager)
public function saveInvoice(EntityManagerInterface $entityManager): mixed
{
$this->saveFailed = true;
$this->validate();
@@ -145,6 +148,8 @@ class InvoiceCreator extends AbstractController
// Keep the lineItems in sync with the invoice: new InvoiceItems may
// not have been given the same key as the original lineItems
$this->lineItems = $this->populateLineItems($this->invoice);
return null;
}
public function getSubtotal(): float
@@ -183,6 +188,9 @@ class InvoiceCreator extends AbstractController
return false;
}
/**
* @return list<array{productId: int|null, quantity: int, isEditing: bool}>
*/
private function populateLineItems(Invoice $invoice): array
{
$lineItems = [];

View File

@@ -23,7 +23,7 @@ use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsLiveComponent]
class InvoiceCreatorLineItem
final class InvoiceCreatorLineItem
{
use DefaultActionTrait;
use ValidatableComponentTrait;
@@ -73,6 +73,9 @@ class InvoiceCreatorLineItem
$this->changeEditMode(true, $responder);
}
/**
* @return list<Product>
*/
#[ExposeInTemplate]
public function getProducts(): array
{

View File

@@ -12,6 +12,7 @@
namespace App\Twig\Components;
use App\Form\MealPlannerForm;
use App\Model\MealPlan;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
@@ -19,11 +20,14 @@ use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class MealPlanner extends AbstractController
final class MealPlanner extends AbstractController
{
use ComponentWithFormTrait;
use DefaultActionTrait;
/**
* @return FormInterface<MealPlan>
*/
protected function instantiateForm(): FormInterface
{
return $this->createForm(MealPlannerForm::class);

View File

@@ -23,7 +23,7 @@ use Symfony\UX\LiveComponent\LiveResponder;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
#[AsLiveComponent]
class NewCategoryForm
final class NewCategoryForm
{
use ComponentToolsTrait;
use DefaultActionTrait;

View File

@@ -28,7 +28,7 @@ use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsLiveComponent]
class NewProductForm extends AbstractController
final class NewProductForm extends AbstractController
{
use DefaultActionTrait;
use ValidatableComponentTrait;
@@ -48,6 +48,9 @@ class NewProductForm extends AbstractController
#[NotBlank]
public ?Category $category = null;
/**
* @return list<Category>
*/
#[ExposeInTemplate]
public function getCategories(): array
{

View File

@@ -15,7 +15,7 @@ use App\Model\UxPackage;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('PackageBox')]
class PackageBox
final class PackageBox
{
public UxPackage $package;

View File

@@ -17,7 +17,7 @@ use App\Service\UxPackageRepository;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent('PackageHeader', template: 'components/Package/PackageHeader.html.twig')]
class PackageHeader
final class PackageHeader
{
public UxPackage $package;

View File

@@ -17,7 +17,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent('SearchPackages', template: 'components/Package/SearchPackages.html.twig')]
class SearchPackages
final class SearchPackages
{
use DefaultActionTrait;
@@ -28,6 +28,9 @@ class SearchPackages
{
}
/**
* @return list<\App\Model\UxPackage>
*/
public function getPackages(): array
{
return $this->packageRepo->findAll($this->query);

View File

@@ -19,7 +19,7 @@ use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent('ProductGrid')]
class ProductGrid
final class ProductGrid
{
use ComponentToolsTrait;
use DefaultActionTrait;
@@ -44,6 +44,9 @@ class ProductGrid
return \count($this->emojis) > ($this->page * self::PER_PAGE);
}
/**
* @return list<array{id: int, emoji: string, color: string}>
*/
public function getItems(): array
{
$emojis = $this->emojis->paginate($this->page, self::PER_PAGE);
@@ -61,6 +64,9 @@ class ProductGrid
return $items;
}
/**
* @return list<string>
*/
public function getColors(): array
{
return [

View File

@@ -20,7 +20,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
#[AsLiveComponent('ProductGrid2')]
class ProductGrid2
final class ProductGrid2
{
use ComponentToolsTrait;
use DefaultActionTrait;
@@ -34,6 +34,9 @@ class ProductGrid2
{
}
/**
* @return list<array{id: int, emoji: string}>
*/
public function getItems(): array
{
$items = [];

View File

@@ -21,7 +21,7 @@ use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class RegistrationForm extends AbstractController
final class RegistrationForm extends AbstractController
{
use ComponentWithFormTrait;
use DefaultActionTrait;
@@ -31,6 +31,9 @@ class RegistrationForm extends AbstractController
#[LiveProp]
public ?string $newUserEmail = null;
/**
* @phpstan-ignore missingType.generics
*/
protected function instantiateForm(): FormInterface
{
return $this->createForm(RegistrationFormType::class);
@@ -42,7 +45,7 @@ class RegistrationForm extends AbstractController
}
#[LiveAction]
public function saveRegistration()
public function saveRegistration(): void
{
$this->submitForm();

View File

@@ -15,7 +15,7 @@ use App\Util\SourceCleaner;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Terminal
final class Terminal
{
public int $bottomPadding = 100;
public string $height = 'auto';

View File

@@ -21,7 +21,7 @@ use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\LiveCollectionTrait;
#[AsLiveComponent]
class TodoListForm extends AbstractController
final class TodoListForm extends AbstractController
{
use DefaultActionTrait;
use LiveCollectionTrait;
@@ -29,6 +29,9 @@ class TodoListForm extends AbstractController
#[LiveProp(fieldName: 'formData')]
public ?TodoList $todoList;
/**
* @return FormInterface<TodoList>
*/
protected function instantiateForm(): FormInterface
{
return $this->createForm(

View File

@@ -18,16 +18,11 @@ use Symfony\UX\Toolkit\Recipe\Recipe;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class ComponentDoc
final class ComponentDoc
{
public ToolkitKitId $kitId;
public Recipe $component;
/**
* @see https://regex101.com/r/8L2pPy/1
*/
private const RE_CODE_BLOCK = '/```(?P<language>[a-z]+?)\s*(?P<options>\{.+?\})?\n(?P<code>.+?)```/s';
public function __construct(
private readonly ToolkitService $toolkitService,
private readonly \Twig\Environment $twig,

View File

@@ -22,7 +22,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
class UploadFiles
final class UploadFiles
{
use DefaultActionTrait;
@@ -35,6 +35,7 @@ class UploadFiles
#[LiveProp]
public ?string $singleFileUploadError = null;
/** @var list<array{filename: string, size: int|false}> */
#[LiveProp]
public array $multipleUploadFilenames = [];
@@ -59,6 +60,9 @@ class UploadFiles
}
}
/**
* @return array{0: string, 1: int|false}
*/
private function processFileUpload(UploadedFile $file): array
{
// in a real app, move this file somewhere
@@ -82,6 +86,6 @@ class UploadFiles
$this->singleFileUploadError = $errors->get(0)->getMessage();
// causes the component to re-render
throw new UnprocessableEntityHttpException('Validation failed');
throw new UnprocessableEntityHttpException('Validation failed.');
}
}

View File

@@ -24,6 +24,9 @@ final class ToolkitRuntime implements RuntimeExtensionInterface
) {
}
/**
* @param array<string, mixed> $options
*/
public function codeExample(string $kitId, string $recipeName, string $exampleName, array $options = [], bool $preview = true): string
{
$kitId = ToolkitKitId::from($kitId);
@@ -76,11 +79,17 @@ final class ToolkitRuntime implements RuntimeExtensionInterface
);
}
/**
* @param array<string, mixed> $options
*/
public function codeDemo(string $kitId, string $recipeName, array $options = []): string
{
return $this->codeExample($kitId, $recipeName, 'Demo', $options + ['height' => '450px'], preview: true);
}
/**
* @param array<string, mixed> $options
*/
public function codeUsage(string $kitId, string $recipeName, array $options = []): string
{
return $this->codeExample($kitId, $recipeName, 'Usage', $options, preview: false);

View File

@@ -37,7 +37,7 @@ class SourceCleaner
// Unindent all lines by 4 spaces
$lines = explode("\n", $contents);
$lines = array_map(function (string $line) {
$lines = array_map(static function (string $line) {
return substr($line, 4);
}, $lines);
$contents = u(implode("\n", $lines));
@@ -50,7 +50,7 @@ class SourceCleaner
{
$lines = explode("\n", $content);
$lines = array_map(function (string $line) {
$lines = array_map(static function (string $line) {
$line = trim($line);
if (!$line) {
@@ -129,7 +129,7 @@ class SourceCleaner
}
// remove the minimum indentation from each line
$blockLines = array_map(function (string $line) use ($leastIndentedLineCount) {
$blockLines = array_map(static function (string $line) use ($leastIndentedLineCount) {
return substr($line, $leastIndentedLineCount);
}, $blockLines);
@@ -156,7 +156,7 @@ class SourceCleaner
public static function removeExcessHtml(string $content): string
{
// remove all HTML attributes and values + whitespace around them
$content = preg_replace_callback('/\s+[a-z0-9-]+="[^"]*"/', function ($matches) {
$content = preg_replace_callback('/\s+[a-z0-9-]+="[^"]*"/', static function ($matches) {
if (str_starts_with(trim($matches[0]), 'data-')) {
return $matches[0];
}
@@ -167,12 +167,10 @@ class SourceCleaner
// Find all the <div> elements without attributes
preg_match_all('/<div>\s*(.*?)\s*<\/div>/s', $content, $matches);
if (isset($matches[1])) {
// Loop through the found matches
foreach ($matches[1] as $match) {
// Replace the div tags without attributes with their content
$content = preg_replace('/<div>\s*'.preg_quote($match, '/').'\s*<\/div>/s', $match, $content);
}
// Loop through the found matches
foreach ($matches[1] as $match) {
// Replace the div tags without attributes with their content
$content = preg_replace('/<div>\s*'.preg_quote($match, '/').'\s*<\/div>/s', $match, $content);
}
$lines = explode("\n", $content);

View File

@@ -107,6 +107,18 @@
"phar-io/version": {
"version": "3.2.1"
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"phpunit/php-code-coverage": {
"version": "9.2.15"
},

View File

@@ -25,6 +25,9 @@ class RedirectUrlTest extends WebTestCase
$this->assertResponseRedirects($expectedUrl, $expectedStatusCode);
}
/**
* @return list<array{0: string, 1: string, 2: int}>
*/
protected static function getRedirectionTests(): array
{
return [

View File

@@ -19,9 +19,14 @@ class ChangelogItemTest extends TestCase
public function testSetItem()
{
$component = new ChangelogItem();
$component->item = ['body' => 'foobar'];
$component->item = [
'id' => 1,
'name' => 'Test',
'version' => 'v1.0.0',
'date' => '2024-01-01',
'body' => 'foobar',
];
$this->assertSame(['body' => 'foobar'], $component->item);
$this->assertSame('foobar', $component->getContent());
}
@@ -32,12 +37,19 @@ class ChangelogItemTest extends TestCase
{
$component = new ChangelogItem();
$component->item = [
'id' => 1,
'name' => 'Test',
'version' => 'v1.0.0',
'date' => '2024-01-01',
'body' => $body,
];
$this->assertSame($expected, $component->getContent());
}
/**
* @return iterable<string, array{0: string, 1: string}>
*/
public static function provideContentValues(): iterable
{
yield 'keep_existing_h1' => [

View File

@@ -13,9 +13,7 @@ use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
if ($_SERVER['APP_DEBUG']) {
umask(0o000);

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
* This file is used by PHPStan, see https://github.com/phpstan/phpstan-symfony#console-command-analysis.
*/
declare(strict_types=1);
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Dotenv\Dotenv;
new Dotenv()->bootEnv(__DIR__ . '/../../.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
return new Application($kernel);

View File

@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
* This file is used by PHPStan, see https://github.com/phpstan/phpstan-doctrine
*/
declare(strict_types=1);
use App\Kernel;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Dotenv\Dotenv;
new Dotenv()->bootEnv(__DIR__ . '/../../.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
/** @var EntityManager $entityManager */
$entityManager = $kernel->getContainer()->get('doctrine')->getManager();
return $entityManager;

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
* This file is used by PHPStan, see https://github.com/phpstan/phpstan-symfony#console-command-analysis.
*/
declare(strict_types=1);
require __DIR__.'/../../vendor/autoload.php';
$env = getenv('APP_ENV') ?: 'test';
$xmlContainerFile = __DIR__.sprintf('/../../var/cache/%s/App_Kernel%sDebugContainer.xml', $env, ucfirst($env));
if (!file_exists($xmlContainerFile)) {
throw new RuntimeException(sprintf(<<<ERROR
PHPStan depends on the meta information the Symfony Dependency Injection that the compiler pass writes.
The meta xml file could not be found: %s.
To compile the Symfony container do a cache:clear in the current env (%s) with debug: true!
ERROR, $xmlContainerFile, $env));
}
return [
'parameters' => [
'symfony' => [
'containerXmlPath' => $xmlContainerFile,
],
],
];