From 646a2e5a8e18ec6043cbb0fd8bd4127e7ea004c2 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 18 Jun 2026 22:01:00 +0300 Subject: [PATCH 1/5] Fix all PHPStan errors and empty the baseline Resolve every PHPStan (level max) error across the Platform, Saas, Ui and Rector tooling so phpstan-baseline.neon no longer ignores anything. Fixes address root causes only - no @phpstan-ignore, no baseline entries, no assert()/inline @var overrides, no silencing casts, and no type widening: - validate external LemonSqueezy payloads via runtime-checked accessors - give Symfony config arrays precise array-shape PHPDoc - add generics to Doctrine repositories and narrow query results - correct return types and remove dead instanceof/match branches - narrow dynamic test data with real PHPUnit assertions --- phpstan-baseline.neon | 1471 +---------------- .../AuthenticationCompilerPass.php | 24 +- .../CompilerPass/MenuCompilerPass.php | 4 + .../Extension/TwoFactorExtension.php | 2 +- .../SolidWorxPlatformExtension.php | 9 +- src/Bundle/Platform/Feature/FeatureValue.php | 13 +- .../Platform/Form/Type/Security/LoginType.php | 6 +- .../Type/Security/TwoFactorVerifyType.php | 8 +- src/Bundle/Platform/Kernel.php | 68 +- src/Bundle/Platform/Menu/Options.php | 4 +- src/Bundle/Platform/Menu/Provider.php | 2 +- src/Bundle/Platform/Model/User.php | 5 + .../Platform/Repository/UserRepository.php | 22 +- .../Platform/Routing/LoginPageRouteLoader.php | 15 +- .../TwoFactor/Traits/UserTwoFactor.php | 20 +- .../Platform/SolidWorxPlatformBundle.php | 10 +- .../Twig/Components/Security/TwoFactor.php | 6 +- .../Saas/Config/Builder/SaasConfigBuilder.php | 30 +- src/Bundle/Saas/Config/SaasConfiguration.php | 15 +- .../Command/SubscriptionListCommand.php | 19 +- .../EventSubscriber/PlanIdSubscriber.php | 2 +- src/Bundle/Saas/Dto/LemonSqueezy/Meta.php | 3 + src/Bundle/Saas/Entity/Plan.php | 3 + src/Bundle/Saas/Event/TrialStartedEvent.php | 2 +- .../SubscriptionEventSubscriber.php | 2 +- .../Saas/Feature/PlanFeatureManager.php | 5 +- src/Bundle/Saas/Integration/LemonSqueezy.php | 144 +- src/Bundle/Saas/Integration/Options.php | 6 + .../LemonSqueezyWebhookConsumer.php | 85 +- .../SubscriptionPaymentRemoteEvent.php | 3 + .../RemoteEvent/SubscriptionRemoteEvent.php | 3 + .../PlanFeatureRepositoryInterface.php | 4 + src/Bundle/Saas/Repository/PlanRepository.php | 20 +- .../Saas/Subscription/SubscriptionManager.php | 10 +- .../LemonSqueezyPayloadConverter.php | 87 +- src/Test/Traits/UsesFixturesTrait.php | 14 +- .../Rules/AddGenericTemplateExtendsRector.php | 30 +- .../EnforcePlatformEntityRepositoryRector.php | 11 +- .../Builder/PlatformConfigBuilderTest.php | 43 +- .../Config/SchemaGeneratorTest.php | 103 +- .../Response/RedirectResponseTest.php | 3 +- .../Config/Builder/SaasConfigBuilderTest.php | 51 +- .../Saas/Feature/PlanFeatureManagerTest.php | 10 +- .../LemonSqueezyWebhookConsumerTest.php | 2 +- .../Ui/Config/Builder/UiConfigBuilderTest.php | 31 +- 45 files changed, 726 insertions(+), 1704 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1a98413..aab4991 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,1471 +1,2 @@ parameters: - ignoreErrors: - - - rawMessage: 'Argument of an invalid type array|bool|float|int|string|null supplied for foreach, only iterables are supported.' - identifier: foreach.nonIterable - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: 'Binary operation "+" between mixed and array{remember_me_parameter: null, always_remember_me: false} results in an error.' - identifier: binaryOp.invalid - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: Binary operation "." between 'security…' and mixed results in an error. - identifier: binaryOp.invalid - count: 4 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: Cannot access offset 'always_remember_me' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: Cannot access offset 'remember_me_parameter' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: Possibly invalid array key type mixed. - identifier: offsetAccess.invalidOffset - count: 3 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php - - - - rawMessage: Cannot access offset 'alias' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php - - - - rawMessage: Cannot access offset 'method' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php - - - - rawMessage: Cannot access offset 'priority' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php - - - - rawMessage: Cannot access offset 'role' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php - - - - rawMessage: 'Default value of the parameter #2 $config (array{}) of method SolidWorx\Platform\PlatformBundle\DependencyInjection\Extension\TwoFactorExtension::enable() is incompatible with type array{name: string, base_template: string}.' - identifier: parameter.defaultValue - count: 1 - path: src/Bundle/Platform/DependencyInjection/Extension/TwoFactorExtension.php - - - - rawMessage: 'Parameter #2 $configurator of method Symfony\Component\DependencyInjection\ContainerBuilder::registerAttributeForAutoconfiguration() expects callable(Symfony\Component\DependencyInjection\ChildDefinition, SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder, Reflector): void, Closure(Symfony\Component\DependencyInjection\ChildDefinition, SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder, ReflectionMethod): void given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php - - - - rawMessage: 'Parameter #2 $array of function implode expects array, array given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Feature/FeatureValue.php - - - - rawMessage: 'Parameter #1 $child of method Symfony\Component\Form\FormBuilderInterface|null>::add() expects string|Symfony\Component\Form\FormBuilderInterface, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Platform/Form/Type/Security/LoginType.php - - - - rawMessage: Cannot cast mixed to string. - identifier: cast.string - count: 1 - path: src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php - - - - rawMessage: 'Class SolidWorx\Platform\PlatformBundle\Form\Type\TextEditorType extends generic class Symfony\Component\Form\AbstractType but does not specify its types: TData' - identifier: missingType.generics - count: 1 - path: src/Bundle/Platform/Form/Type/TextEditorType.php - - - - rawMessage: Cannot access offset 'enabled' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: Cannot access offset 'security' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: Cannot access offset 'two_factor' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: Cannot cast mixed to string. - identifier: cast.string - count: 2 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: 'Generator expects value type Symfony\Component\HttpKernel\Bundle\BundleInterface, mixed given.' - identifier: generator.valueType - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: 'Parameter #1 $rawConfig of method SolidWorx\Platform\PlatformBundle\Config\PlatformConfigSectionInterface::setPlatformRawConfig() expects array, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: 'Property SolidWorx\Platform\PlatformBundle\Kernel::$rawConfig (array|null) does not accept mixed.' - identifier: assign.propertyType - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: ''' - Return type of method SolidWorx\Platform\PlatformBundle\Kernel::registerBundles() has typehint with deprecated interface Symfony\Component\HttpKernel\Bundle\BundleInterface: - since Symfony 8.1, use Symfony\Component\DependencyInjection\Kernel\BundleInterface instead - ''' - identifier: return.deprecatedInterface - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: Short ternary operator is not allowed. Use null coalesce operator if applicable or consider using long ternary. - identifier: ternary.shortNotAllowed - count: 1 - path: src/Bundle/Platform/Kernel.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Menu\Options::route() has parameter $parameters with no value type specified in iterable type array.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Platform/Menu/Options.php - - - - rawMessage: 'Property SolidWorx\Platform\PlatformBundle\Menu\Options::$options (array{route?: string, routeParameters?: array, routeAbsolute?: bool}) does not accept array{route?: string, routeParameters: array, routeAbsolute?: bool}.' - identifier: assign.propertyType - count: 1 - path: src/Bundle/Platform/Menu/Options.php - - - - rawMessage: 'Property SolidWorx\Platform\PlatformBundle\Menu\Options::$options (array{route?: string, routeParameters?: array, routeAbsolute?: bool}) does not accept array{route?: string, routeParameters?: array, routeAbsolute?: bool, extras: array{icon: string}}.' - identifier: assign.propertyType - count: 1 - path: src/Bundle/Platform/Menu/Options.php - - - - rawMessage: 'Property SolidWorx\Platform\PlatformBundle\Menu\Options::$options (array{route?: string, routeParameters?: array, routeAbsolute?: bool}) does not accept array{route?: string, routeParameters?: array, routeAbsolute?: bool, extras: array{role: string}}.' - identifier: assign.propertyType - count: 1 - path: src/Bundle/Platform/Menu/Options.php - - - - rawMessage: 'Property SolidWorx\Platform\PlatformBundle\Menu\Options::$options (array{route?: string, routeParameters?: array, routeAbsolute?: bool}) does not accept array{route?: string, routeParameters?: array, routeAbsolute?: bool, extras: array{}}.' - identifier: assign.propertyType - count: 2 - path: src/Bundle/Platform/Menu/Options.php - - - - rawMessage: 'Only booleans are allowed in &&, mixed given on the left side.' - identifier: booleanAnd.leftNotBoolean - count: 1 - path: src/Bundle/Platform/Menu/Provider.php - - - - rawMessage: 'Parameter #2 $priority of method SplPriorityQueue::insert() expects int, array given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Menu/Provider.php - - - - rawMessage: Casting to bool something that's already bool. - identifier: cast.useless - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Model\User::getBackUpCodes() does not match interface method name: SolidWorx\Platform\PlatformBundle\Contracts\Security\TwoFactor\UserTwoFactorInterface::getBackupCodes().' - identifier: method.nameCase - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Model\User::getBackUpCodes() should return list but returns array.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Model\User::getEmailAuthRecipient() should return string but returns string|null.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Model\User::getTotpAuthenticationUsername() should return string but returns string|null.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Model\User::getUserIdentifier() should return non-empty-string but returns string|null.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: Property SolidWorx\Platform\PlatformBundle\Model\User::$backupCodes type has no value type specified in iterable type array. - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Platform/Model/User.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Repository\UserRepository::loadUserByIdentifier() should return Symfony\Component\Security\Core\User\UserInterface but returns mixed.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/Repository/UserRepository.php - - - - rawMessage: 'Parameter #1 $identifier of method SolidWorx\Platform\PlatformBundle\Repository\UserRepository::loadUserByIdentifier() expects string, string|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Repository/UserRepository.php - - - - rawMessage: 'Parameter #2 $entityClass of method Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository::__construct() expects class-string, string|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Repository/UserRepository.php - - - - rawMessage: Type mixed in generic type SolidWorx\Platform\PlatformBundle\Repository\EntityRepository in PHPDoc tag @extends is not subtype of template type T of object of class SolidWorx\Platform\PlatformBundle\Repository\EntityRepository. - identifier: generics.notSubtype - count: 1 - path: src/Bundle/Platform/Repository/UserRepository.php - - - - rawMessage: Binary operation "." between '/' and mixed results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Binary operation "." between 'The "check_path"…' and mixed results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Binary operation "." between '_login_' and mixed results in an error. - identifier: binaryOp.invalid - count: 3 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'always_remember_me' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'check_path' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 5 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'csrf_parameter' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'csrf_token_id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'enable_csrf' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'login_path' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'password_parameter' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'remember_me_parameter' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot access offset 'username_parameter' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: Cannot cast mixed to string. - identifier: cast.string - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Routing\LoginPageRouteLoader::__construct() has parameter $authenticators with no value type specified in iterable type iterable.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: 'Parameter #1 $name of method Symfony\Component\Routing\RouteCollection::add() expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: 'Parameter $path of class Symfony\Component\Routing\Route constructor expects string, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Platform/Routing/LoginPageRouteLoader.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\SolidWorxPlatformBundle::createContainerExtension() never returns null so it can be removed from the return type.' - identifier: return.unusedType - count: 1 - path: src/Bundle/Platform/SolidWorxPlatformBundle.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\SolidWorxPlatformBundle::createContainerExtension() should return Symfony\Component\DependencyInjection\Extension\ExtensionInterface|null but returns object.' - identifier: return.type - count: 1 - path: src/Bundle/Platform/SolidWorxPlatformBundle.php - - - - rawMessage: Cannot access offset 'secret' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Platform/Twig/Components/Security/TwoFactor.php - - - - rawMessage: 'Method SolidWorx\Platform\PlatformBundle\Twig\Components\Security\TwoFactor::instantiateForm() return type with generic interface Symfony\Component\Form\FormInterface does not specify its types: TData' - identifier: missingType.generics - count: 1 - path: src/Bundle/Platform/Twig/Components/Security/TwoFactor.php - - - - rawMessage: 'Parameter #1 $totpSecret of method SolidWorx\Platform\PlatformBundle\Contracts\Security\TwoFactor\UserTwoFactorInterface::setTotpSecret() expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Platform/Twig/Components/Security/TwoFactor.php - - - - rawMessage: Cannot access offset 'entity' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php - - - - rawMessage: Cannot access offset 'lemon_squeezy' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php - - - - rawMessage: Cannot access offset 'subscriptions' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php - - - - rawMessage: Cannot access offset 'table_names' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php - - - - rawMessage: Cannot access offset 'user_entity' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php - - - - rawMessage: Cannot cast mixed to string. - identifier: cast.string - count: 5 - path: src/Bundle/Saas/Config/SaasConfiguration.php - - - - rawMessage: 'Parameter #1 $object_or_class of function is_subclass_of expects object|string, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Saas/Config/SaasConfiguration.php - - - - rawMessage: Binary operation "." between mixed and '@' results in an error. - identifier: binaryOp.invalid - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: Binary operation "." between non-falsy-string and mixed results in an error. - identifier: binaryOp.invalid - count: 2 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Call to static method getClass() on an unknown class Doctrine\Common\Util\ClassUtils.' - identifier: class.notFound - count: 2 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Console\Command\SubscriptionListCommand::getSubscriberString() never returns Symfony\Component\Uid\Ulid so it can be removed from the return type.' - identifier: return.unusedType - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Console\Command\SubscriptionListCommand::getSubscriberString() never returns int so it can be removed from the return type.' - identifier: return.unusedType - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Only booleans are allowed in &&, mixed given on the left side.' - identifier: booleanAnd.leftNotBoolean - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Only booleans are allowed in &&, mixed given on the right side.' - identifier: booleanAnd.rightNotBoolean - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Only booleans are allowed in an if condition, mixed given.' - identifier: if.condNotBoolean - count: 3 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Parameter #1 $value of static method SolidWorx\Platform\SaasBundle\Enum\SubscriptionStatus::from() expects int|string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Console/Command/SubscriptionListCommand.php - - - - rawMessage: 'Parameter #1 $string of function strtolower expects string, Symfony\Component\String\AbstractUnicodeString given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Doctrine/EventSubscriber/PlanIdSubscriber.php - - - - rawMessage: Property SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Meta::$customData type has no value type specified in iterable type array. - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/Dto/LemonSqueezy/Meta.php - - - - rawMessage: 'Property SolidWorx\Platform\SaasBundle\Entity\Plan::$subscriptions type mapping mismatch: property can contain Doctrine\Common\Collections\Collection but database expects Doctrine\Common\Collections\Collection&iterable.' - identifier: doctrine.associationType - count: 1 - path: src/Bundle/Saas/Entity/Plan.php - - - - rawMessage: 'Property SolidWorx\Platform\SaasBundle\Entity\Plan::$subscriptions with generic interface Doctrine\Common\Collections\Collection does not specify its types: TKey, T' - identifier: missingType.generics - count: 1 - path: src/Bundle/Saas/Entity/Plan.php - - - - rawMessage: Cannot access property $attributes on SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null. - identifier: property.nonObject - count: 1 - path: src/Bundle/Saas/Event/TrialStartedEvent.php - - - - rawMessage: 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/EventSubscriber/SubscriptionEventSubscriber.php - - - - rawMessage: 'Parameter #1 $plans of class SolidWorx\Platform\PlatformBundle\Feature\UpgradeOptions constructor expects list, array given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Feature/PlanFeatureGate.php - - - - rawMessage: 'Call to an undefined method SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface::remove().' - identifier: method.notFound - count: 1 - path: src/Bundle/Saas/Feature/PlanFeatureManager.php - - - - rawMessage: 'Call to an undefined method SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface::save().' - identifier: method.notFound - count: 1 - path: src/Bundle/Saas/Feature/PlanFeatureManager.php - - - - rawMessage: 'Call to an undefined method Symfony\Contracts\Cache\CacheInterface::clear().' - identifier: method.notFound - count: 1 - path: src/Bundle/Saas/Feature/PlanFeatureManager.php - - - - rawMessage: 'Argument of an invalid type mixed supplied for foreach, only iterables are supported.' - identifier: foreach.nonIterable - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'attributes' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 8 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'customer_portal' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'description' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'ends_at' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'links' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'name' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'price-model' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'related' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'relationships' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'renewal_interval_quantity' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'renewal_interval_unit' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'renews_at' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'unit_price' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'url' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'urls' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: Cannot access offset 'variants' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Integration\LemonSqueezy::checkout() should return string but returns mixed.' - identifier: return.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Integration\LemonSqueezy::getCustomerPortalUrl() should return string but returns mixed.' - identifier: return.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter #1 $payload of method SolidWorx\Platform\SaasBundle\Integration\LemonSqueezy::extractRenewDate() expects array, array given.' - identifier: argument.type - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter #2 $url of method Symfony\Contracts\HttpClient\HttpClientInterface::request() expects string, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter #3 ...$values of function sprintf expects bool|float|int|string|null, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter $description of class SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct constructor expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter $id of class SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct constructor expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter $name of class SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct constructor expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Parameter $price of class SolidWorx\Platform\SaasBundle\Dto\IntegrationProduct constructor expects int, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Integration/LemonSqueezy.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Integration\Options::toArray() return type has no value type specified in iterable type array.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/Integration/Options.php - - - - rawMessage: Property SolidWorx\Platform\SaasBundle\Integration\Options::$options type has no value type specified in iterable type array. - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/Integration/Options.php - - - - rawMessage: Access to an undefined property SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionPaymentRemoteEvent|SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionRemoteEvent::$subscription. - identifier: property.notFound - count: 8 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: Access to an undefined property SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionPaymentRemoteEvent|SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionRemoteEvent::$subscriptionInvoice. - identifier: property.notFound - count: 5 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Call to function is_a() with arguments ''SolidWorx\\Platform\\SaasBundle\\Event\\SubscriptionPaymentFailedEvent''|''SolidWorx\\Platform\\SaasBundle\\Event\\SubscriptionPaymentPaidEvent''|''SolidWorx\\Platform\\SaasBundle\\Event\\SubscriptionPaymentRecoveredEvent''|''SolidWorx\\Platform\\SaasBundle\\Event\\SubscriptionPaymentRefundedEvent'', ''SolidWorx\\Platform\\SaasBundle\\Event\\PaymentEvent'' and true will always evaluate to true.' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: Cannot access offset 'id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: Match arm comparison between SolidWorx\Platform\SaasBundle\Enum\LemonSqueezy\Event::SUBSCRIPTION_PAYMENT_REFUNDED and SolidWorx\Platform\SaasBundle\Enum\LemonSqueezy\Event::SUBSCRIPTION_PAYMENT_REFUNDED is always true. - identifier: match.alwaysTrue - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #1 $gatewayEventId of method SolidWorx\Platform\SaasBundle\Entity\WebhookEventLog::setGatewayEventId() expects string|null, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #2 ...$values of function sprintf expects bool|float|int|string|null, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionCancelledEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionCreatedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionExpiredEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionPausedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionResumedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionUnpausedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscription of class SolidWorx\Platform\SaasBundle\Event\SubscriptionUpdatedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscriptionInvoice of class SolidWorx\Platform\SaasBundle\Event\SubscriptionPaymentFailedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscriptionInvoice of class SolidWorx\Platform\SaasBundle\Event\SubscriptionPaymentPaidEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscriptionInvoice of class SolidWorx\Platform\SaasBundle\Event\SubscriptionPaymentRecoveredEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Parameter #3 $subscriptionInvoice of class SolidWorx\Platform\SaasBundle\Event\SubscriptionPaymentRefundedEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null, SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription|SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice|null given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionPaymentRemoteEvent::__construct() has parameter $payload with no value type specified in iterable type array.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/RemoteEvent/SubscriptionPaymentRemoteEvent.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionRemoteEvent::__construct() has parameter $payload with no value type specified in iterable type array.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::findAllOrdered() should return list but returns mixed.' - identifier: return.type - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::findDefault() should return SolidWorx\Platform\SaasBundle\Entity\Plan|null but returns mixed.' - identifier: return.type - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Parameter #1 $id (SolidWorx\Platform\SaasBundle\Entity\Plan|string|Symfony\Component\Uid\Ulid) of method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::find() should be contravariant with parameter $id (mixed) of method Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository::find()' - identifier: method.childParameterType - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Parameter #1 $id (SolidWorx\Platform\SaasBundle\Entity\Plan|string|Symfony\Component\Uid\Ulid) of method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::find() should be contravariant with parameter $id (mixed) of method Doctrine\Persistence\ObjectRepository::find()' - identifier: method.childParameterType - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Parameter #2 $lockMode (int|null) of method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::find() should be contravariant with parameter $lockMode (Doctrine\DBAL\LockMode|int|null) of method Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository::find()' - identifier: method.childParameterType - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Parameter #2 $lockMode (int|null) of method SolidWorx\Platform\SaasBundle\Repository\PlanRepository::find() should be contravariant with parameter $lockMode (Doctrine\DBAL\LockMode|int|null) of method SolidWorx\Platform\SaasBundle\Repository\PlanRepositoryInterface::find()' - identifier: method.childParameterType - count: 1 - path: src/Bundle/Saas/Repository/PlanRepository.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\SolidWorxPlatformSaasBundle::createContainerExtension() never returns null so it can be removed from the return type.' - identifier: return.unusedType - count: 1 - path: src/Bundle/Saas/SolidWorxPlatformSaasBundle.php - - - - rawMessage: 'Parameter #1 $plan of class SolidWorx\Platform\SaasBundle\Exception\InvalidPlanException constructor expects string, SolidWorx\Platform\SaasBundle\Entity\Plan|string|Symfony\Component\Uid\Ulid given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Subscription/SubscriptionManager.php - - - - rawMessage: Cannot access offset 'custom_data' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: Cannot access offset 'event_name' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: Cannot access offset 'id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: Cannot access offset 'subscription_id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: Cannot access offset 'type' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: Cannot cast mixed to string. - identifier: cast.string - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Webhook\Converter\LemonSqueezyPayloadConverter::convert() has parameter $payload with no value type specified in iterable type array.' - identifier: missingType.iterableValue - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #1 $name of class Symfony\Component\RemoteEvent\RemoteEvent constructor expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #1 $ulid of static method Symfony\Component\Uid\Ulid::fromString() expects string, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #1 $value of static method SolidWorx\Platform\SaasBundle\Enum\LemonSqueezy\Event::from() expects int|string, mixed given.' - identifier: argument.type - count: 2 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #2 $id of class Symfony\Component\RemoteEvent\RemoteEvent constructor expects string, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #2 $subscription of class SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionRemoteEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\Subscription, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #2 $subscriptionInvoice of class SolidWorx\Platform\SaasBundle\RemoteEvent\SubscriptionPaymentRemoteEvent constructor expects SolidWorx\Platform\SaasBundle\Dto\LemonSqueezy\SubscriptionInvoice, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Parameter #3 $payload of class Symfony\Component\RemoteEvent\RemoteEvent constructor expects array, mixed given.' - identifier: argument.type - count: 1 - path: src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php - - - - rawMessage: 'Method SolidWorx\Platform\UiBundle\SolidWorxPlatformUiBundle::createContainerExtension() never returns null so it can be removed from the return type.' - identifier: return.unusedType - count: 1 - path: src/Bundle/Ui/SolidWorxPlatformUiBundle.php - - - - rawMessage: ''' - Access to deprecated property $file of class Rector\Rector\AbstractRector: - Use getFile() instead. - ''' - identifier: property.deprecated - count: 1 - path: src/Tools/Rector/Rules/AddGenericMethodPhpDocRector.php - - - - rawMessage: 'Only booleans are allowed in an if condition, int|false given.' - identifier: if.condNotBoolean - count: 2 - path: src/Tools/Rector/Rules/AddGenericMethodPhpDocRector.php - - - - rawMessage: 'Parameter #1 $node (PhpParser\Node\Stmt\ClassMethod) of method SolidWorx\Platform\Tools\Rector\Rules\AddGenericMethodPhpDocRector::refactor() should be contravariant with parameter $node (PhpParser\Node) of method Rector\Contract\Rector\RectorInterface::refactor()' - identifier: method.childParameterType - count: 2 - path: src/Tools/Rector/Rules/AddGenericMethodPhpDocRector.php - - - - rawMessage: 'Method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::countOwnTemplateTags() has parameter $reflection with generic class ReflectionClass but does not specify its types: T' - identifier: missingType.generics - count: 1 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::countOwnTemplateTags() should return int but returns int<0, max>|false.' - identifier: return.type - count: 1 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Parameter #1 $node (PhpParser\Node\Stmt\Class_) of method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::refactor() should be contravariant with parameter $node (PhpParser\Node) of method Rector\Contract\Rector\RectorInterface::refactor()' - identifier: method.childParameterType - count: 2 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Parameter #1 $objectOrClass of class ReflectionClass constructor expects class-string|T of object, string given.' - identifier: argument.type - count: 2 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Parameter #1 $optionsArg of method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::isFieldUnmapped() expects PhpParser\Node\Arg|null, PhpParser\Node\Arg|PhpParser\Node\VariadicPlaceholder|null given.' - identifier: argument.type - count: 1 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Parameter #1 $typeArg of method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::resolveFormFieldPhpType() expects PhpParser\Node\Arg|null, PhpParser\Node\Arg|PhpParser\Node\VariadicPlaceholder|null given.' - identifier: argument.type - count: 1 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: 'Parameter #2 $optionsArg of method SolidWorx\Platform\Tools\Rector\Rules\AddGenericTemplateExtendsRector::resolveFormFieldPhpType() expects PhpParser\Node\Arg|null, PhpParser\Node\Arg|PhpParser\Node\VariadicPlaceholder|null given.' - identifier: argument.type - count: 1 - path: src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php - - - - rawMessage: Instanceof between PHPStan\PhpDocParser\Ast\Type\GenericTypeNode and PHPStan\PhpDocParser\Ast\Type\GenericTypeNode will always evaluate to true. - identifier: instanceof.alwaysTrue - count: 1 - path: src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php - - - - rawMessage: 'Only booleans are allowed in a while condition, ReflectionClass|false given.' - identifier: while.condNotBoolean - count: 1 - path: src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php - - - - rawMessage: 'Parameter #1 $node (PhpParser\Node\Stmt\Class_) of method SolidWorx\Platform\Tools\Rector\Rules\EnforcePlatformEntityRepositoryRector::refactor() should be contravariant with parameter $node (PhpParser\Node) of method Rector\Contract\Rector\RectorInterface::refactor()' - identifier: method.childParameterType - count: 2 - path: src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php - - - - rawMessage: 'Parameter #1 $objectOrClass of class ReflectionClass constructor expects class-string|T of object, string given.' - identifier: argument.type - count: 1 - path: src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php - - - - rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertInstanceOf() with ''SolidWorx\\Platform\\PlatformBundle\\Config\\Builder\\SecurityConfigBuilder'' and SolidWorx\Platform\PlatformBundle\Config\Builder\SecurityConfigBuilder will always evaluate to true.' - identifier: staticMethod.alreadyNarrowedType - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'doctrine' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'enable_utc_date' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'models' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'name' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'saas' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'types' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'ui' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'user' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'version' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayNotHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 4 - path: tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'active' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'additionalProperties' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'anything' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'color' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'count' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'db' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'default' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'description' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'driver' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'enabled' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'enum' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'items' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'name' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'nested' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'options' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'platform' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 23 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'properties' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 28 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'ratio' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'required_field' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'tags' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'template' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'title' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'two_factor' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'type' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 14 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'ui' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: Cannot access offset 'user' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 15 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayNotHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 5 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: 'Parameter #2 $haystack of static method PHPUnit\Framework\Assert::assertContains() expects iterable, mixed given.' - identifier: argument.type - count: 1 - path: tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php - - - - rawMessage: 'Call to function is_a() with arguments ''SolidWorx\\Platform\\PlatformBundle\\Response\\RedirectResponse'', ''Symfony\\Component\\HttpFoundation\\RedirectResponse'' and true will always evaluate to true.' - identifier: function.alreadyNarrowedType - count: 1 - path: tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php - - - - rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertInstanceOf() with ''SolidWorx\\Platform\\SaasBundle\\Config\\Builder\\SaasPaymentConfigBuilder'' and SolidWorx\Platform\SaasBundle\Config\Builder\SaasPaymentConfigBuilder will always evaluate to true.' - identifier: staticMethod.alreadyNarrowedType - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'api_key' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'db_schema' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'enabled' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'entity' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'lemon_squeezy' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'plan' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'return_route' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'store_id' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'subscriptions' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'table_names' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'trial' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'user_entity' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'webhook_secret' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayNotHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: 'Parameter #2 $haystack of static method PHPUnit\Framework\Assert::assertCount() expects Countable|iterable, mixed given.' - identifier: argument.type - count: 1 - path: tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php - - - - rawMessage: 'Method SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface@anonymous/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php:219::findByPlans() should return array but returns array.' - identifier: return.type - count: 1 - path: tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php - - - - rawMessage: Instanceof between *NEVER* and SolidWorx\Platform\SaasBundle\Event\PaymentEvent will always evaluate to false. - identifier: instanceof.alwaysFalse - count: 1 - path: tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php - - - - rawMessage: Instanceof between *NEVER* and SolidWorx\Platform\SaasBundle\Event\SubscriptionEvent will always evaluate to false. - identifier: instanceof.alwaysFalse - count: 1 - path: tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php - - - - rawMessage: 'Parameter #2 $expectedEventClass of method Bundle\Saas\RemoteEvent\LemonSqueezyWebhookConsumerTest::testConsume() expects class-string, string given.' - identifier: argument.type - count: 12 - path: tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php - - - - rawMessage: Result of && is always false. - identifier: booleanAnd.alwaysFalse - count: 2 - path: tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php - - - - rawMessage: 'Method Bundle\Saas\Webhook\Converter\LemonSqueezyPayloadConverterTest::loadFixture() should return array but returns array.' - identifier: return.type - count: 1 - path: tests/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverterTest.php - - - - rawMessage: Cannot access offset 'base' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php - - - - rawMessage: Cannot access offset 'login' on mixed. - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php - - - - rawMessage: 'Parameter #2 $array of static method PHPUnit\Framework\Assert::assertArrayHasKey() expects array|ArrayAccess<(int|string), mixed>, mixed given.' - identifier: argument.type - count: 1 - path: tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php + ignoreErrors: [] diff --git a/src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php b/src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php index 5f0ffc9..5e45263 100644 --- a/src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php +++ b/src/Bundle/Platform/DependencyInjection/CompilerPass/AuthenticationCompilerPass.php @@ -18,6 +18,9 @@ use Symfony\Component\DependencyInjection\Argument\IteratorArgument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use function is_array; +use function is_iterable; +use function is_string; final class AuthenticationCompilerPass implements CompilerPassInterface { @@ -39,15 +42,28 @@ private function registerLoginRouteLoader(ContainerBuilder $container): void $routeLoader = $container->getDefinition(LoginPageRouteLoader::class); $firewalls = $container->getParameter('security.firewalls'); + if (! is_iterable($firewalls)) { + return; + } + $authenticators = []; foreach ($firewalls as $firewall) { + if (! is_string($firewall)) { + continue; + } + if (! $container->hasDefinition('security.authenticator.form_login.' . $firewall)) { continue; } $authenticator = $container->getDefinition('security.authenticator.form_login.' . $firewall); - $authenticators[$firewall] = $authenticator->getArgument(4) + [ + $options = $authenticator->getArgument(4); + if (! is_array($options)) { + continue; + } + + $authenticators[$firewall] = $options + [ 'remember_me_parameter' => null, 'always_remember_me' => false, ]; @@ -55,8 +71,10 @@ private function registerLoginRouteLoader(ContainerBuilder $container): void if ($container->hasDefinition('security.authenticator.remember_me_handler.' . $firewall)) { $rememberMeArguments = $container->getDefinition('security.authenticator.remember_me_handler.' . $firewall)->getArgument(3); - $authenticators[$firewall]['remember_me_parameter'] = $rememberMeArguments['remember_me_parameter']; - $authenticators[$firewall]['always_remember_me'] = $rememberMeArguments['always_remember_me']; + if (is_array($rememberMeArguments)) { + $authenticators[$firewall]['remember_me_parameter'] = $rememberMeArguments['remember_me_parameter'] ?? null; + $authenticators[$firewall]['always_remember_me'] = $rememberMeArguments['always_remember_me'] ?? false; + } } } diff --git a/src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php b/src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php index 66288e4..a36ee36 100644 --- a/src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php +++ b/src/Bundle/Platform/DependencyInjection/CompilerPass/MenuCompilerPass.php @@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; +use function is_array; final class MenuCompilerPass implements CompilerPassInterface { @@ -37,6 +38,9 @@ public function process(ContainerBuilder $container): void foreach ($taggedServices as $id => $tagAttributes) { foreach ($tagAttributes as $attributes) { + if (! is_array($attributes)) { + continue; + } $wrapperDefinition = new Definition(Closure::class) ->addArgument([new Reference($id), $attributes['method']]) diff --git a/src/Bundle/Platform/DependencyInjection/Extension/TwoFactorExtension.php b/src/Bundle/Platform/DependencyInjection/Extension/TwoFactorExtension.php index 6c7dca0..b71fac9 100644 --- a/src/Bundle/Platform/DependencyInjection/Extension/TwoFactorExtension.php +++ b/src/Bundle/Platform/DependencyInjection/Extension/TwoFactorExtension.php @@ -28,7 +28,7 @@ final class TwoFactorExtension /** * @param array{name: string, base_template: string} $config */ - public static function enable(ContainerBuilder $container, array $config = []): void + public static function enable(ContainerBuilder $container, array $config): void { $container ->setDefinition( diff --git a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php index 879b609..71c8145 100644 --- a/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php +++ b/src/Bundle/Platform/DependencyInjection/SolidWorxPlatformExtension.php @@ -16,6 +16,7 @@ use Knp\Menu\Provider\MenuProviderInterface; use Override; use ReflectionMethod; +use Reflector; use SolidWorx\Platform\PlatformBundle\Attributes\Menu\MenuBuilder; use SolidWorx\Platform\PlatformBundle\Config\PlatformConfiguration; use SolidWorx\Platform\PlatformBundle\Controller\Security\ResendTwoFactorCode; @@ -70,10 +71,14 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(MenuProviderInterface::class) ->addTag('knp_menu.provider'); - $container->registerAttributeForAutoconfiguration(MenuBuilder::class, static function (ChildDefinition $definition, MenuBuilder $attribute, ReflectionMethod $reflectionMethod): void { + $container->registerAttributeForAutoconfiguration(MenuBuilder::class, static function (ChildDefinition $definition, MenuBuilder $attribute, Reflector $reflector): void { + if (! $reflector instanceof ReflectionMethod) { + return; + } + $definition->addTag(Util::tag('menu.builder'), [ 'alias' => $attribute->name, - 'method' => $reflectionMethod->getName(), + 'method' => $reflector->getName(), 'priority' => $attribute->priority, 'role' => $attribute->role, ]); diff --git a/src/Bundle/Platform/Feature/FeatureValue.php b/src/Bundle/Platform/Feature/FeatureValue.php index f04390d..40ac057 100644 --- a/src/Bundle/Platform/Feature/FeatureValue.php +++ b/src/Bundle/Platform/Feature/FeatureValue.php @@ -13,9 +13,11 @@ namespace SolidWorx\Platform\PlatformBundle\Feature; +use Stringable; use function is_array; use function is_bool; use function is_int; +use function is_scalar; final readonly class FeatureValue { @@ -75,7 +77,16 @@ public function asBool(): bool public function asString(): string { if (is_array($this->value)) { - return implode(',', $this->value); + $parts = []; + foreach ($this->value as $item) { + if (is_scalar($item)) { + $parts[] = (string) $item; + } elseif ($item instanceof Stringable) { + $parts[] = (string) $item; + } + } + + return implode(',', $parts); } if (is_bool($this->value)) { diff --git a/src/Bundle/Platform/Form/Type/Security/LoginType.php b/src/Bundle/Platform/Form/Type/Security/LoginType.php index bb9f9b8..c15c079 100644 --- a/src/Bundle/Platform/Form/Type/Security/LoginType.php +++ b/src/Bundle/Platform/Form/Type/Security/LoginType.php @@ -22,11 +22,11 @@ use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\NotBlank; -/** - * @extends AbstractType - */ final class LoginType extends AbstractType { + /** + * @param array{username_parameter: string, password_parameter: string} $options + */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { diff --git a/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php b/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php index 2ad4100..a42525c 100644 --- a/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php +++ b/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php @@ -22,11 +22,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; -/** - * @extends AbstractType - */ final class TwoFactorVerifyType extends AbstractType { + /** + * @param array{secret: string} $options + */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { @@ -44,7 +44,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], 'constraints' => [ new NotBlank(), - new TwoFactorCode(secret: (string) $options['secret']), + new TwoFactorCode(secret: $options['secret']), ], ]) ->add('secret', HiddenType::class, [ diff --git a/src/Bundle/Platform/Kernel.php b/src/Bundle/Platform/Kernel.php index 75909dc..66e56c8 100644 --- a/src/Bundle/Platform/Kernel.php +++ b/src/Bundle/Platform/Kernel.php @@ -25,6 +25,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Bundle\BundleInterface; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; @@ -33,7 +34,9 @@ use function file_exists; use function glob; use function implode; +use function is_array; use function is_file; +use function is_string; use function pathinfo; use function sprintf; @@ -73,9 +76,7 @@ public function registerBundles(): iterable { yield from parent::registerBundles(); - $platformConfig = $this->rawConfig['platform'] ?? []; - - if (($platformConfig['security']['two_factor']['enabled'] ?? false) === true) { + if ($this->isTwoFactorEnabled()) { yield new SchebTwoFactorBundle(); } @@ -93,7 +94,7 @@ protected function initializeBundles(): void $section = $key !== '' ? ($this->rawConfig[$key] ?? []) : ($this->rawConfig['platform'] ?? []); - $bundle->setPlatformRawConfig($section); + $bundle->setPlatformRawConfig(self::toConfigArray($section)); } } } @@ -113,6 +114,26 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->import('.', '_solidworx_platform_auth_routes'); } + private function isTwoFactorEnabled(): bool + { + $platformConfig = $this->rawConfig['platform'] ?? []; + if (! is_array($platformConfig)) { + return false; + } + + $security = $platformConfig['security'] ?? []; + if (! is_array($security)) { + return false; + } + + $twoFactor = $security['two_factor'] ?? []; + if (! is_array($twoFactor)) { + return false; + } + + return ($twoFactor['enabled'] ?? false) === true; + } + private function processPlatformConfig(): void { if ($this->rawConfig !== null) { @@ -136,15 +157,9 @@ private function processPlatformConfig(): void $ext = pathinfo($configFile, PATHINFO_EXTENSION); $this->rawConfig = match ($ext) { - 'yaml', 'yml' => Yaml::parseFile($configFile, Yaml::PARSE_CONSTANT) ?? [], - 'json' => (static function (string $path): array { - $decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); - return is_array($decoded) ? $decoded : []; - })($configFile), - 'php' => (static function (string $path): array { - $result = require $path; - return is_array($result) ? $result : []; - })($configFile), + 'yaml', 'yml' => self::toConfigArray(Yaml::parseFile($configFile, Yaml::PARSE_CONSTANT)), + 'json' => self::toConfigArray(json_decode((string) file_get_contents($configFile), true, 512, JSON_THROW_ON_ERROR)), + 'php' => self::toConfigArray(require $configFile), default => throw new RuntimeException(sprintf('Unsupported platform configuration file format: .%s', $ext)), }; } @@ -155,6 +170,29 @@ private function processPlatformConfig(): void $this->publishPlatformConfigState(); } + /** + * Normalises a decoded configuration value into a string-keyed map. + * + * The root of a configuration file is always an associative map, so any non-array + * value is treated as empty and the keys are normalised to strings. + * + * @return array + */ + private static function toConfigArray(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $config = []; + + foreach ($value as $key => $item) { + $config[(string) $key] = $item; + } + + return $config; + } + /** * Publishes the parsed `platform:` section so compile-time helpers (e.g. the security * config helpers) can read it while the container is being built. @@ -170,8 +208,8 @@ private function resolveConfigFile(): ?string { // Allow an explicit path override via environment variable $envOverride = $_ENV['PLATFORM_CONFIG_FILE'] ?? $_SERVER['PLATFORM_CONFIG_FILE'] ?? null; - if ($envOverride !== null && is_file((string) $envOverride)) { - return (string) $envOverride; + if (is_string($envOverride) && is_file($envOverride)) { + return $envOverride; } $glob = $this->getProjectDir() . '/' . self::DEFAULT_CONFIG_GLOB; diff --git a/src/Bundle/Platform/Menu/Options.php b/src/Bundle/Platform/Menu/Options.php index 42b93ae..00cbcfb 100644 --- a/src/Bundle/Platform/Menu/Options.php +++ b/src/Bundle/Platform/Menu/Options.php @@ -20,6 +20,7 @@ class Options * route?: string, * routeParameters?: array, * routeAbsolute?: bool, + * extras?: array{role?: string, icon?: string}, * } */ private array $options = []; @@ -33,7 +34,7 @@ public static function create(): self return new self(); } - /*** + /** * @param array $parameters */ public function route(string $route, array $parameters = [], bool $absolute = false): self @@ -63,6 +64,7 @@ public function icon(string $icon): self * route?: string, * routeParameters?: array, * routeAbsolute?: bool, + * extras?: array{role?: string, icon?: string}, * } */ public function build(): array diff --git a/src/Bundle/Platform/Menu/Provider.php b/src/Bundle/Platform/Menu/Provider.php index a89bb2f..1f63b08 100644 --- a/src/Bundle/Platform/Menu/Provider.php +++ b/src/Bundle/Platform/Menu/Provider.php @@ -23,7 +23,7 @@ final class Provider implements MenuProviderInterface { /** - * @var array> + * @var array> */ private array $list = []; diff --git a/src/Bundle/Platform/Model/User.php b/src/Bundle/Platform/Model/User.php index fc46336..d21610c 100644 --- a/src/Bundle/Platform/Model/User.php +++ b/src/Bundle/Platform/Model/User.php @@ -17,6 +17,7 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use EmailChecker\Constraints as EmailCheckerAssert; +use LogicException; use Override; use SolidWorx\Platform\PlatformBundle\Contracts\Security\TwoFactor\UserTwoFactorInterface; use SolidWorx\Platform\PlatformBundle\Security\TwoFactor\Traits\UserTwoFactor; @@ -135,6 +136,10 @@ public function getId(): ?Ulid #[Override] public function getUserIdentifier(): string { + if ($this->email === null || $this->email === '') { + throw new LogicException('Cannot resolve the user identifier because the email is not set.'); + } + return $this->email; } diff --git a/src/Bundle/Platform/Repository/UserRepository.php b/src/Bundle/Platform/Repository/UserRepository.php index 8770f32..5b44dc6 100644 --- a/src/Bundle/Platform/Repository/UserRepository.php +++ b/src/Bundle/Platform/Repository/UserRepository.php @@ -28,14 +28,13 @@ use function sprintf; /** - * @template T - * @extends EntityRepository + * @template-extends EntityRepository */ class UserRepository extends EntityRepository implements UserRepositoryInterface, UserLoaderInterface { - public function __construct(ManagerRegistry $registry, ?string $className = User::class) + public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, $className); + parent::__construct($registry, User::class); } public function refreshUser(UserInterface $user): UserInterface @@ -47,7 +46,12 @@ public function refreshUser(UserInterface $user): UserInterface assert($user instanceof User); - return $this->loadUserByIdentifier($user->getEmail()); + $email = $user->getEmail(); + if ($email === null) { + throw new UnsupportedUserException('Cannot refresh a user without an email address.'); + } + + return $this->loadUserByIdentifier($email); } public function supportsClass(string $class): bool @@ -73,9 +77,15 @@ public function loadUserByIdentifier(string $identifier): UserInterface try { // The Query::getSingleResult() method throws an exception if there is no record matching the criteria. - return $q->getSingleResult(); + $user = $q->getSingleResult(); } catch (NoResultException|NonUniqueResultException $e) { throw new UserNotFoundException(sprintf('User "%s" does not exist.', $identifier), 0, $e); } + + if (! $user instanceof UserInterface) { + throw new UserNotFoundException(sprintf('User "%s" does not exist.', $identifier)); + } + + return $user; } } diff --git a/src/Bundle/Platform/Routing/LoginPageRouteLoader.php b/src/Bundle/Platform/Routing/LoginPageRouteLoader.php index 41606fb..2f3e56b 100644 --- a/src/Bundle/Platform/Routing/LoginPageRouteLoader.php +++ b/src/Bundle/Platform/Routing/LoginPageRouteLoader.php @@ -26,6 +26,19 @@ ])] class LoginPageRouteLoader extends Loader { + /** + * @param iterable $authenticators + */ public function __construct( private readonly iterable $authenticators, ?string $env = null, @@ -39,7 +52,7 @@ public function load(mixed $resource, ?string $type = null): mixed $collection = new RouteCollection(); foreach ($this->authenticators as $id => $authenticator) { - if (str_starts_with((string) $authenticator['check_path'], '/')) { + if (str_starts_with($authenticator['check_path'], '/')) { $collection->add( $checkPath = '_login_' . $id . '_check_path', new Route( diff --git a/src/Bundle/Platform/Security/TwoFactor/Traits/UserTwoFactor.php b/src/Bundle/Platform/Security/TwoFactor/Traits/UserTwoFactor.php index 4ae2d96..4377de7 100644 --- a/src/Bundle/Platform/Security/TwoFactor/Traits/UserTwoFactor.php +++ b/src/Bundle/Platform/Security/TwoFactor/Traits/UserTwoFactor.php @@ -19,6 +19,7 @@ use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration; use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface; use function array_search; +use function array_values; use function in_array; trait UserTwoFactor @@ -37,6 +38,9 @@ trait UserTwoFactor ])] private int $trustedVersion = 0; + /** + * @var list|null + */ #[ORM\Column(name: 'backup_codes', type: 'json', nullable: true)] private ?array $backupCodes = []; @@ -55,6 +59,10 @@ public function setTotpSecret(string $totpSecret): self public function getTotpAuthenticationUsername(): string { // @TODO: email property should not be hard-coded + if ($this->email === null) { + throw new LogicException('Cannot resolve the TOTP authentication username because the email is not set.'); + } + return $this->email; } @@ -74,6 +82,10 @@ public function isEmailAuthEnabled(): bool public function getEmailAuthRecipient(): string { // @TODO: email property should not be hard-coded + if ($this->email === null) { + throw new LogicException('Cannot resolve the email authentication recipient because the email is not set.'); + } + return $this->email; } @@ -107,14 +119,16 @@ public function getTrustedTokenVersion(): int public function isBackupCode(string $code): bool { - return in_array($code, (array) $this->backupCodes, true); + return in_array($code, $this->backupCodes ?? [], true); } public function invalidateBackupCode(string $code): void { - $key = array_search($code, (array) $this->backupCodes, true); + $codes = $this->backupCodes ?? []; + $key = array_search($code, $codes, true); if ($key !== false) { - unset($this->backupCodes[$key]); + unset($codes[$key]); + $this->backupCodes = array_values($codes); } } diff --git a/src/Bundle/Platform/SolidWorxPlatformBundle.php b/src/Bundle/Platform/SolidWorxPlatformBundle.php index 861dc35..bb2e746 100644 --- a/src/Bundle/Platform/SolidWorxPlatformBundle.php +++ b/src/Bundle/Platform/SolidWorxPlatformBundle.php @@ -14,6 +14,7 @@ namespace SolidWorx\Platform\PlatformBundle; use Doctrine\DBAL\Exception; +use LogicException; use Knp\Bundle\MenuBundle\KnpMenuBundle; use Override; use SolidWorx\Platform\PlatformBundle\Config\PlatformConfigSectionInterface; @@ -26,6 +27,7 @@ use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\DependencyInjection\Kernel\RequiredBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; +use function sprintf; use Symfony\UX\Icons\UXIconsBundle; use Twig\Extra\TwigExtraBundle\TwigExtraBundle; @@ -94,6 +96,12 @@ public function getPath(): string #[Override] protected function createContainerExtension(): ExtensionInterface { - return new ($this->getContainerExtensionClass())($this->rawConfig); + $extension = new ($this->getContainerExtensionClass())($this->rawConfig); + + if (! $extension instanceof ExtensionInterface) { + throw new LogicException(sprintf('Extension "%s" must implement "%s".', $extension::class, ExtensionInterface::class)); + } + + return $extension; } } diff --git a/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php b/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php index 57f5595..0c7b34d 100644 --- a/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php +++ b/src/Bundle/Platform/Twig/Components/Security/TwoFactor.php @@ -39,6 +39,8 @@ use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\Attribute\PreMount; use function assert; +use function is_array; +use function is_string; #[AsLiveComponent(name: 'Platform:Security:TwoFactor', template: '@SolidWorxPlatform/Components/Security/two_factor.html.twig')] final class TwoFactor extends AbstractController @@ -138,7 +140,9 @@ public function enableTOTPAuth(): void $data = $this->getForm()->getData(); - $secret = $data['secret'] ?? $this->totpSecret; + $secret = is_array($data) && isset($data['secret']) && is_string($data['secret']) + ? $data['secret'] + : $this->totpSecret; $user = $this->getUser(); assert($user instanceof UserTwoFactorInterface); diff --git a/src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php b/src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php index 8c8473e..8e7a2cc 100644 --- a/src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php +++ b/src/Bundle/Saas/Config/Builder/SaasConfigBuilder.php @@ -119,16 +119,28 @@ public function build(): array { $config = []; + $doctrine = []; + if ($this->subscriptionEntity !== null) { - $config['doctrine']['subscriptions']['entity'] = $this->subscriptionEntity; + $doctrine['subscriptions'] = [ + 'entity' => $this->subscriptionEntity, + ]; } if ($this->trialUserEntity !== null) { - $config['doctrine']['trial']['user_entity'] = $this->trialUserEntity; + $doctrine['trial'] = [ + 'user_entity' => $this->trialUserEntity, + ]; } if ($this->tableNames !== []) { - $config['doctrine']['db_schema']['table_names'] = $this->tableNames; + $doctrine['db_schema'] = [ + 'table_names' => $this->tableNames, + ]; + } + + if ($doctrine !== []) { + $config['doctrine'] = $doctrine; } if ($this->payment instanceof SaasPaymentConfigBuilder) { @@ -136,11 +148,13 @@ public function build(): array } if ($this->lemonSqueezyEnabled) { - $config['integration']['lemon_squeezy'] = [ - 'enabled' => true, - 'api_key' => $this->lemonSqueezyApiKey, - 'webhook_secret' => $this->lemonSqueezyWebhookSecret, - 'store_id' => $this->lemonSqueezyStoreId, + $config['integration'] = [ + 'lemon_squeezy' => [ + 'enabled' => true, + 'api_key' => $this->lemonSqueezyApiKey, + 'webhook_secret' => $this->lemonSqueezyWebhookSecret, + 'store_id' => $this->lemonSqueezyStoreId, + ], ]; } diff --git a/src/Bundle/Saas/Config/SaasConfiguration.php b/src/Bundle/Saas/Config/SaasConfiguration.php index ee0e5c6..3bb2a88 100644 --- a/src/Bundle/Saas/Config/SaasConfiguration.php +++ b/src/Bundle/Saas/Config/SaasConfiguration.php @@ -24,6 +24,7 @@ use SolidWorx\Platform\SaasBundle\Trial\TrialUserInterface; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use function in_array; +use function is_string; use function is_subclass_of; use function sprintf; @@ -58,7 +59,7 @@ public function getTreeBuilder(): TreeBuilder ->cannotBeEmpty() ->info(sprintf('The class name of the subscription entity. Must implement %s', SubscribableInterface::class)) ->validate() - ->ifTrue(fn ($v): bool => ! is_subclass_of($v, SubscribableInterface::class)) + ->ifTrue(static fn (mixed $v): bool => ! is_string($v) || ! is_subclass_of($v, SubscribableInterface::class)) ->thenInvalid(sprintf('The subscription entity must implement %s', SubscribableInterface::class)) ->end() ->end() @@ -71,7 +72,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultNull() ->info(sprintf('The class name of the user entity for trial tracking. Must implement %s', TrialUserInterface::class)) ->validate() - ->ifTrue(fn ($v): bool => $v !== null && ! is_subclass_of($v, TrialUserInterface::class)) + ->ifTrue(static fn (mixed $v): bool => $v !== null && (! is_string($v) || ! is_subclass_of($v, TrialUserInterface::class))) ->thenInvalid(sprintf('The trial user entity must implement %s', TrialUserInterface::class)) ->end() ->end() @@ -87,7 +88,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultValue(Plan::TABLE_NAME) ->info('The table name for the Plan entity') ->validate() - ->ifTrue(fn ($value): bool => in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', (string) $value), [0, false], true)) + ->ifTrue(static fn (mixed $value): bool => ! is_string($value) || in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', $value), [0, false], true)) ->thenInvalid('The table name is not valid') ->end() ->end() @@ -95,7 +96,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultValue(Subscription::TABLE_NAME) ->info('The table name for the Subscription entity') ->validate() - ->ifTrue(fn ($value): bool => in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', (string) $value), [0, false], true)) + ->ifTrue(static fn (mixed $value): bool => ! is_string($value) || in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', $value), [0, false], true)) ->thenInvalid('The table name is not valid') ->end() ->end() @@ -103,7 +104,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultValue(SubscriptionLog::TABLE_NAME) ->info('The table name for the Subscription logs entity') ->validate() - ->ifTrue(fn ($value): bool => in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', (string) $value), [0, false], true)) + ->ifTrue(static fn (mixed $value): bool => ! is_string($value) || in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', $value), [0, false], true)) ->thenInvalid('The table name is not valid') ->end() ->end() @@ -111,7 +112,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultValue(PlanFeature::TABLE_NAME) ->info('The table name for the Plan Feature entity') ->validate() - ->ifTrue(fn ($value): bool => in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', (string) $value), [0, false], true)) + ->ifTrue(static fn (mixed $value): bool => ! is_string($value) || in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', $value), [0, false], true)) ->thenInvalid('The table name is not valid') ->end() ->end() @@ -119,7 +120,7 @@ public function getTreeBuilder(): TreeBuilder ->defaultValue(Trial::TABLE_NAME) ->info('The table name for the Trial entity') ->validate() - ->ifTrue(fn ($value): bool => in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', (string) $value), [0, false], true)) + ->ifTrue(static fn (mixed $value): bool => ! is_string($value) || in_array(preg_match('/^(?!\d)[A-Za-z_][A-Za-z0-9_$#]{0,64}$/u', $value), [0, false], true)) ->thenInvalid('The table name is not valid') ->end() ->end() diff --git a/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php b/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php index a93ee34..26b4f3d 100644 --- a/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php +++ b/src/Bundle/Saas/Console/Command/SubscriptionListCommand.php @@ -16,7 +16,7 @@ use Carbon\CarbonImmutable; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Order; -use Doctrine\Common\Util\ClassUtils; +use Doctrine\ORM\Proxy\DefaultProxyClassNameResolver; use Override; use SolidWorx\Platform\PlatformBundle\Console\Command; use SolidWorx\Platform\PlatformBundle\Feature\SubscribableInterface; @@ -27,6 +27,8 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; use function array_map; +use function is_scalar; +use function is_string; // #[AsCommand(name: 'saas:subscription:list', description: 'List all subscriptions')] @@ -56,7 +58,8 @@ protected function handle(): int $criteria = new Criteria(); $expr = Criteria::expr(); - if ($status = $this->io->getOption('status')) { + $status = $this->io->getOption('status'); + if (is_string($status) && $status !== '') { $criteria->andWhere($expr->eq('status', SubscriptionStatus::from($status))); } @@ -116,14 +119,20 @@ protected function handle(): int private function getSubscriberString(SubscribableInterface $subscriber): string { - if ($subscriber instanceof Stringable || method_exists($subscriber, '__toString')) { + if ($subscriber instanceof Stringable) { return (string) $subscriber; } + $class = DefaultProxyClassNameResolver::getClass($subscriber); + if (method_exists($subscriber, 'getId')) { - return $subscriber->getId() . '@' . ClassUtils::getClass($subscriber); + $id = $subscriber->getId(); + + if (is_scalar($id) || $id instanceof Stringable) { + return $id . '@' . $class; + } } - return spl_object_hash($subscriber) . '@' . ClassUtils::getClass($subscriber); + return spl_object_hash($subscriber) . '@' . $class; } } diff --git a/src/Bundle/Saas/Doctrine/EventSubscriber/PlanIdSubscriber.php b/src/Bundle/Saas/Doctrine/EventSubscriber/PlanIdSubscriber.php index c1a8029..235a92f 100644 --- a/src/Bundle/Saas/Doctrine/EventSubscriber/PlanIdSubscriber.php +++ b/src/Bundle/Saas/Doctrine/EventSubscriber/PlanIdSubscriber.php @@ -29,7 +29,7 @@ public function __construct( public function prePersist(Plan $plan): void { if ($plan->getPlanId() === '') { - $plan->setPlanId(strtolower($this->slugger->slug($plan->getName()))); + $plan->setPlanId($this->slugger->slug($plan->getName())->lower()->toString()); } } } diff --git a/src/Bundle/Saas/Dto/LemonSqueezy/Meta.php b/src/Bundle/Saas/Dto/LemonSqueezy/Meta.php index f735551..7799fb3 100644 --- a/src/Bundle/Saas/Dto/LemonSqueezy/Meta.php +++ b/src/Bundle/Saas/Dto/LemonSqueezy/Meta.php @@ -27,6 +27,9 @@ class Meta #[SerializedName('webhook_id')] public string $webhookId; + /** + * @var array + */ #[SerializedName('custom_data')] public array $customData; } diff --git a/src/Bundle/Saas/Entity/Plan.php b/src/Bundle/Saas/Entity/Plan.php index b221981..15dac3b 100644 --- a/src/Bundle/Saas/Entity/Plan.php +++ b/src/Bundle/Saas/Entity/Plan.php @@ -71,6 +71,9 @@ class Plan implements Stringable ])] private bool $active = true; + /** + * @var Collection + */ #[ORM\OneToMany(targetEntity: Subscription::class, mappedBy: 'plan', orphanRemoval: true)] private Collection $subscriptions; diff --git a/src/Bundle/Saas/Event/TrialStartedEvent.php b/src/Bundle/Saas/Event/TrialStartedEvent.php index c4e7098..5e6af24 100644 --- a/src/Bundle/Saas/Event/TrialStartedEvent.php +++ b/src/Bundle/Saas/Event/TrialStartedEvent.php @@ -19,6 +19,6 @@ final class TrialStartedEvent extends SubscriptionEvent { public function getTrialEndDate(): ?DateTimeInterface { - return $this->subscription->attributes->trialEndsAt; + return $this->subscription?->attributes->trialEndsAt; } } diff --git a/src/Bundle/Saas/EventSubscriber/SubscriptionEventSubscriber.php b/src/Bundle/Saas/EventSubscriber/SubscriptionEventSubscriber.php index 2348ce8..1fc1b1e 100644 --- a/src/Bundle/Saas/EventSubscriber/SubscriptionEventSubscriber.php +++ b/src/Bundle/Saas/EventSubscriber/SubscriptionEventSubscriber.php @@ -143,7 +143,7 @@ protected function handleSubscriptionStatus(SubscriptionCreatedEvent | Subscript break; default: - throw new InvalidSubscriptionException(sprintf('Unsupported subscription status: %s', $attrs->status->name)); + throw new InvalidSubscriptionException(sprintf('Unsupported subscription status for subscription "%s".', $event->subscriptionId->toBase58())); } } } diff --git a/src/Bundle/Saas/Feature/PlanFeatureManager.php b/src/Bundle/Saas/Feature/PlanFeatureManager.php index ea6093d..9eb4baf 100644 --- a/src/Bundle/Saas/Feature/PlanFeatureManager.php +++ b/src/Bundle/Saas/Feature/PlanFeatureManager.php @@ -24,6 +24,7 @@ use SolidWorx\Platform\SaasBundle\Exception\UndefinedFeatureException; use SolidWorx\Platform\SaasBundle\Repository\PlanFeatureRepositoryInterface; use SolidWorx\Platform\SaasBundle\Subscription\SubscriptionProviderInterface; +use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Service\ResetInterface; use function get_debug_type; @@ -39,7 +40,7 @@ public function __construct( private FeatureConfigRegistry $configRegistry, private PlanFeatureRepositoryInterface $planFeatureRepository, private SubscriptionProviderInterface $subscriptionProvider, - private CacheInterface $cache, + private CacheInterface&AdapterInterface $cache, ) { } @@ -213,7 +214,7 @@ public function isFeatureAvailableOnAnyPlan(string $featureKey): bool /** * Find all plans that have a specific feature enabled. * - * @return array + * @return list */ public function findPlansWithFeature(string $featureKey, ?Plan $excludePlan = null): array { diff --git a/src/Bundle/Saas/Integration/LemonSqueezy.php b/src/Bundle/Saas/Integration/LemonSqueezy.php index f1f8a8d..1b1e632 100644 --- a/src/Bundle/Saas/Integration/LemonSqueezy.php +++ b/src/Bundle/Saas/Integration/LemonSqueezy.php @@ -72,11 +72,21 @@ public function getPlans(): iterable ); $data = $response->toArray(); + $products = $data['data'] ?? null; - foreach ($data['data'] as $product) { - $attributes = $product['attributes']; + if (! is_array($products)) { + return; + } - $relatedData = $product['relationships']['variants']['links']['related'] ?? null; + foreach ($products as $product) { + if (! is_array($product)) { + continue; + } + + $attributes = $this->arrayValue($product, 'attributes'); + + $relationships = $this->optionalArrayValue($product, 'relationships'); + $relatedData = $this->relatedLink($relationships, 'variants'); if ($relatedData === null) { continue; @@ -88,9 +98,19 @@ public function getPlans(): iterable ); $variantsData = $variants->toArray(); + $variantItems = $variantsData['data'] ?? null; + + if (! is_array($variantItems)) { + continue; + } - foreach ($variantsData['data'] as $variant) { - $priceModel = $variant['relationships']['price-model']['links']['related'] ?? null; + foreach ($variantItems as $variant) { + if (! is_array($variant)) { + continue; + } + + $variantRelationships = $this->optionalArrayValue($variant, 'relationships'); + $priceModel = $this->relatedLink($variantRelationships, 'price-model'); if ($priceModel === null) { continue; @@ -102,20 +122,24 @@ public function getPlans(): iterable ); $priceModelData = $priceModelResponse->toArray(); + $priceModelAttributes = $this->arrayValue( + $this->arrayValue($priceModelData, 'data'), + 'attributes', + ); $interval = CarbonInterval::fromString( sprintf( '%s %s', - $priceModelData['data']['attributes']['renewal_interval_quantity'], - $priceModelData['data']['attributes']['renewal_interval_unit'], + $this->intValue($priceModelAttributes, 'renewal_interval_quantity'), + $this->stringValue($priceModelAttributes, 'renewal_interval_unit'), ) ); yield new IntegrationProduct( - id: $variant['id'], - name: $attributes['name'], - description: $attributes['description'], - price: $priceModelData['data']['attributes']['unit_price'], + id: $this->stringValue($variant, 'id'), + name: $this->stringValue($attributes, 'name'), + description: $this->stringValue($attributes, 'description'), + price: $this->intValue($priceModelAttributes, 'unit_price'), interval: $interval, ); } @@ -173,8 +197,9 @@ public function checkout(Subscription $subscription, ?Options $options = null): ); $data = $response->toArray(); + $attributes = $this->arrayValue($this->arrayValue($data, 'data'), 'attributes'); - return $data['data']['attributes']['url']; + return $this->stringValue($attributes, 'url'); } #[Override] @@ -192,8 +217,12 @@ public function getCustomerPortalUrl(Subscription $subscription): string ); $data = $response->toArray(); + $urls = $this->arrayValue( + $this->arrayValue($this->arrayValue($data, 'data'), 'attributes'), + 'urls', + ); - return $data['data']['attributes']['urls']['customer_portal']; + return $this->stringValue($urls, 'customer_portal'); } #[Override] @@ -237,7 +266,9 @@ public function cancelAtPeriodEnd(Subscription $subscription): DateTimeImmutable $this->assertOk($response->getStatusCode(), 'cancel', $subscriptionId); $data = $response->toArray(); - $endsAt = $data['data']['attributes']['ends_at'] ?? null; + $dataNode = $data['data'] ?? null; + $attributes = is_array($dataNode) ? ($dataNode['attributes'] ?? null) : null; + $endsAt = is_array($attributes) ? ($attributes['ends_at'] ?? null) : null; if (! is_string($endsAt) || $endsAt === '') { throw new PaymentIntegrationException(sprintf( @@ -304,13 +335,92 @@ private function assertOk(int $statusCode, string $action, string $subscriptionI } /** - * @param array $payload + * @param array $data + * + * @return array + */ + private function arrayValue(array $data, string $key): array + { + $value = $data[$key] ?? null; + + if (! is_array($value)) { + throw new PaymentIntegrationException(sprintf('Lemon Squeezy response is missing array key "%s".', $key)); + } + + return $value; + } + + /** + * @param array $data + * + * @return array + */ + private function optionalArrayValue(array $data, string $key): array + { + $value = $data[$key] ?? null; + + return is_array($value) ? $value : []; + } + + /** + * @param array $data + */ + private function stringValue(array $data, string $key): string + { + $value = $data[$key] ?? null; + + if (! is_string($value)) { + throw new PaymentIntegrationException(sprintf('Lemon Squeezy response is missing string key "%s".', $key)); + } + + return $value; + } + + /** + * @param array $data + */ + private function intValue(array $data, string $key): int + { + $value = $data[$key] ?? null; + + if (! is_int($value)) { + throw new PaymentIntegrationException(sprintf('Lemon Squeezy response is missing integer key "%s".', $key)); + } + + return $value; + } + + /** + * @param array $relationships + */ + private function relatedLink(array $relationships, string $relationship): ?string + { + $node = $relationships[$relationship] ?? null; + + if (! is_array($node)) { + return null; + } + + $links = $node['links'] ?? null; + + if (! is_array($links)) { + return null; + } + + $related = $links['related'] ?? null; + + return is_string($related) ? $related : null; + } + + /** + * @param array $payload */ private function extractRenewDate(array $payload, string $subscriptionId): DateTimeImmutable { Assert::allString(array_keys($payload)); - $attributes = $payload['data']['attributes'] ?? []; - $renewsAt = $attributes['renews_at'] ?? $attributes['ends_at'] ?? null; + $data = $payload['data'] ?? null; + $attributes = is_array($data) ? ($data['attributes'] ?? null) : null; + $renewsAt = is_array($attributes) ? ($attributes['renews_at'] ?? $attributes['ends_at'] ?? null) : null; if (! is_string($renewsAt) || $renewsAt === '') { throw new PaymentIntegrationException(sprintf( diff --git a/src/Bundle/Saas/Integration/Options.php b/src/Bundle/Saas/Integration/Options.php index 2934f80..4cee77f 100644 --- a/src/Bundle/Saas/Integration/Options.php +++ b/src/Bundle/Saas/Integration/Options.php @@ -19,6 +19,9 @@ class Options public const string EMAIL = 'email'; + /** + * @var array + */ protected array $options = []; private function __construct() @@ -58,6 +61,9 @@ public function getValue(string $option): mixed return $this->options[$option] ?? null; } + /** + * @return array + */ public function toArray(): array { return $this->options; diff --git a/src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php b/src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php index 76189b6..fca6089 100644 --- a/src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php +++ b/src/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumer.php @@ -48,43 +48,72 @@ public function __construct( #[Override] public function consume(RemoteEvent $event): void { - if (! $event instanceof SubscriptionRemoteEvent && ! $event instanceof SubscriptionPaymentRemoteEvent) { + if ($event instanceof SubscriptionRemoteEvent) { + $domainEvent = $this->createSubscriptionEvent($event); + } elseif ($event instanceof SubscriptionPaymentRemoteEvent) { + $domainEvent = $this->createPaymentEvent($event); + } else { return; } - /** @var SubscriptionRemoteEvent|SubscriptionPaymentRemoteEvent $event */ - - [$eventClass, $object] = match ($event->event) { - Event::SUBSCRIPTION_CREATED => [SubscriptionCreatedEvent::class, $event->subscription], - Event::SUBSCRIPTION_UPDATED => [SubscriptionUpdatedEvent::class, $event->subscription], - Event::SUBSCRIPTION_CANCELLED => [SubscriptionCancelledEvent::class, $event->subscription], - Event::SUBSCRIPTION_RESUMED => [SubscriptionResumedEvent::class, $event->subscription], - Event::SUBSCRIPTION_EXPIRED => [SubscriptionExpiredEvent::class, $event->subscription], - Event::SUBSCRIPTION_PAUSED => [SubscriptionPausedEvent::class, $event->subscription], - Event::SUBSCRIPTION_UNPAUSED => [SubscriptionUnpausedEvent::class, $event->subscription], - Event::SUBSCRIPTION_PAYMENT_SUCCESS => [SubscriptionPaymentPaidEvent::class, $event->subscriptionInvoice], - Event::SUBSCRIPTION_PAYMENT_FAILED => [SubscriptionPaymentFailedEvent::class, $event->subscriptionInvoice], - Event::SUBSCRIPTION_PAYMENT_RECOVERED => [SubscriptionPaymentRecoveredEvent::class, $event->subscriptionInvoice], - Event::SUBSCRIPTION_PAYMENT_REFUNDED => [SubscriptionPaymentRefundedEvent::class, $event->subscriptionInvoice], - default => throw new RejectWebhookException(message: sprintf('Unsupported event type: %s', $event->event->value)), - }; - - $this->eventDispatcher->dispatch(new $eventClass( - $event->subscriptionId, - $object->id, - match (true) { - is_a($eventClass, SubscriptionEvent::class, true) => $event->subscription, - is_a($eventClass, PaymentEvent::class, true) => $event->subscriptionInvoice, - default => null, - } - )); + $this->eventDispatcher->dispatch($domainEvent); $log = $this->requestStack->getCurrentRequest()?->attributes->get('_webhook_event_log'); if ($log instanceof WebhookEventLog) { $log->setEventType($event->getName()); - $log->setGatewayEventId($event->getPayload()['meta']['id'] ?? null); + $log->setGatewayEventId($this->extractGatewayEventId($event)); $log->setExternalSubscriptionId($event->subscriptionId->toBase58()); } } + + private function createSubscriptionEvent(SubscriptionRemoteEvent $event): SubscriptionEvent + { + $eventClass = match ($event->event) { + Event::SUBSCRIPTION_CREATED => SubscriptionCreatedEvent::class, + Event::SUBSCRIPTION_UPDATED => SubscriptionUpdatedEvent::class, + Event::SUBSCRIPTION_CANCELLED => SubscriptionCancelledEvent::class, + Event::SUBSCRIPTION_RESUMED => SubscriptionResumedEvent::class, + Event::SUBSCRIPTION_EXPIRED => SubscriptionExpiredEvent::class, + Event::SUBSCRIPTION_PAUSED => SubscriptionPausedEvent::class, + Event::SUBSCRIPTION_UNPAUSED => SubscriptionUnpausedEvent::class, + default => throw new RejectWebhookException(message: sprintf('Unsupported event type: %s', $event->event->value)), + }; + + return new $eventClass( + $event->subscriptionId, + $event->subscription->id, + $event->subscription, + ); + } + + private function createPaymentEvent(SubscriptionPaymentRemoteEvent $event): PaymentEvent + { + $eventClass = match ($event->event) { + Event::SUBSCRIPTION_PAYMENT_SUCCESS => SubscriptionPaymentPaidEvent::class, + Event::SUBSCRIPTION_PAYMENT_FAILED => SubscriptionPaymentFailedEvent::class, + Event::SUBSCRIPTION_PAYMENT_RECOVERED => SubscriptionPaymentRecoveredEvent::class, + Event::SUBSCRIPTION_PAYMENT_REFUNDED => SubscriptionPaymentRefundedEvent::class, + default => throw new RejectWebhookException(message: sprintf('Unsupported event type: %s', $event->event->value)), + }; + + return new $eventClass( + $event->subscriptionId, + $event->subscriptionInvoice->id, + $event->subscriptionInvoice, + ); + } + + private function extractGatewayEventId(RemoteEvent $event): ?string + { + $meta = $event->getPayload()['meta'] ?? null; + + if (! is_array($meta)) { + return null; + } + + $id = $meta['id'] ?? null; + + return is_string($id) ? $id : null; + } } diff --git a/src/Bundle/Saas/RemoteEvent/SubscriptionPaymentRemoteEvent.php b/src/Bundle/Saas/RemoteEvent/SubscriptionPaymentRemoteEvent.php index cb4f6fa..ccc2568 100644 --- a/src/Bundle/Saas/RemoteEvent/SubscriptionPaymentRemoteEvent.php +++ b/src/Bundle/Saas/RemoteEvent/SubscriptionPaymentRemoteEvent.php @@ -20,6 +20,9 @@ final class SubscriptionPaymentRemoteEvent extends RemoteEvent { + /** + * @param array $payload + */ public function __construct( public readonly Ulid $subscriptionId, public readonly SubscriptionInvoice $subscriptionInvoice, diff --git a/src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.php b/src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.php index 08312c9..02a99ce 100644 --- a/src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.php +++ b/src/Bundle/Saas/RemoteEvent/SubscriptionRemoteEvent.php @@ -20,6 +20,9 @@ final class SubscriptionRemoteEvent extends RemoteEvent { + /** + * @param array $payload + */ public function __construct( public readonly Ulid $subscriptionId, public readonly Subscription $subscription, diff --git a/src/Bundle/Saas/Repository/PlanFeatureRepositoryInterface.php b/src/Bundle/Saas/Repository/PlanFeatureRepositoryInterface.php index 9dacfce..dbe47bf 100644 --- a/src/Bundle/Saas/Repository/PlanFeatureRepositoryInterface.php +++ b/src/Bundle/Saas/Repository/PlanFeatureRepositoryInterface.php @@ -38,4 +38,8 @@ public function findByPlans(array $plans): array; * @return array */ public function findByFeatureKey(string $featureKey): array; + + public function save(PlanFeature $entity, bool $flush = true): void; + + public function remove(PlanFeature $entity, bool $flush = true): void; } diff --git a/src/Bundle/Saas/Repository/PlanRepository.php b/src/Bundle/Saas/Repository/PlanRepository.php index dc8ec30..e602acf 100644 --- a/src/Bundle/Saas/Repository/PlanRepository.php +++ b/src/Bundle/Saas/Repository/PlanRepository.php @@ -19,6 +19,11 @@ use SolidWorx\Platform\PlatformBundle\Repository\EntityRepository; use SolidWorx\Platform\SaasBundle\Entity\Plan; use Symfony\Component\Uid\Ulid; +use function array_filter; +use function array_values; +use function is_array; +use function is_iterable; +use function iterator_to_array; /** * @template-extends EntityRepository @@ -66,7 +71,7 @@ public function findDefault(): ?Plan return $default; } - return $this->createQueryBuilder('p') + $fallback = $this->createQueryBuilder('p') ->where('p.active = :active') ->setParameter('active', true) ->orderBy('p.price', 'ASC') @@ -74,6 +79,8 @@ public function findDefault(): ?Plan ->setMaxResults(1) ->getQuery() ->getOneOrNullResult(); + + return $fallback instanceof Plan ? $fallback : null; } /** @@ -81,12 +88,21 @@ public function findDefault(): ?Plan */ public function findAllOrdered(): array { - return $this->createQueryBuilder('p') + $result = $this->createQueryBuilder('p') ->where('p.active = :active') ->setParameter('active', true) ->orderBy('p.price', 'ASC') ->addOrderBy('p.name', 'ASC') ->getQuery() ->getResult(); + + if (! is_iterable($result)) { + return []; + } + + return array_values(array_filter( + is_array($result) ? $result : iterator_to_array($result), + static fn (mixed $plan): bool => $plan instanceof Plan, + )); } } diff --git a/src/Bundle/Saas/Subscription/SubscriptionManager.php b/src/Bundle/Saas/Subscription/SubscriptionManager.php index 8612997..31203c1 100644 --- a/src/Bundle/Saas/Subscription/SubscriptionManager.php +++ b/src/Bundle/Saas/Subscription/SubscriptionManager.php @@ -31,7 +31,6 @@ use SolidWorx\Platform\SaasBundle\Repository\PlanRepositoryInterface; use SolidWorx\Platform\SaasBundle\Repository\SubscriptionRepositoryInterface; use Symfony\Component\Uid\Ulid; -use function get_debug_type; final readonly class SubscriptionManager implements SubscriptionProviderInterface { @@ -57,11 +56,10 @@ public function createSubscription( $plan = $this->planRepository->find($planId); if (! $plan instanceof Plan) { - $planIdString = match (get_debug_type($planId)) { - 'string' => $planId, - Ulid::class => $planId->toBase58(), - Plan::class => $planId->getPlanId(), - default => (string) $planId, + $planIdString = match (true) { + $planId instanceof Ulid => $planId->toBase58(), + $planId instanceof Plan => $planId->getPlanId(), + default => $planId, }; throw new InvalidPlanException($planIdString); diff --git a/src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php b/src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php index 1a83dfa..0108624 100644 --- a/src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php +++ b/src/Bundle/Saas/Webhook/Converter/LemonSqueezyPayloadConverter.php @@ -45,42 +45,91 @@ public function __construct() } /** + * @param array $payload + * * @throws ExceptionInterface */ #[Override] public function convert(array $payload): RemoteEvent { - $type = $this->getMappingClass((string) ($payload['data']['type'] ?? '')); + $data = $this->arrayValue($payload, 'data'); + $meta = $this->arrayValue($payload, 'meta'); + + $type = $this->getMappingClass($this->stringValue($data, 'type')); if ($type === null) { return new RemoteEvent( - $payload['meta']['event_name'], - $payload['meta']['id'], - $payload['data'] + $this->stringValue($meta, 'event_name'), + $this->stringValue($meta, 'id'), + $data, ); } - if (! isset($payload['meta']['custom_data']['subscription_id'])) { + $customData = $this->arrayValue($meta, 'custom_data'); + $subscriptionId = $customData['subscription_id'] ?? null; + + if (! is_string($subscriptionId)) { throw new ParseException('Payload does not contain required custom_data.subscription_id field.'); } - $data = $this->serializer->denormalize($payload['data'], $type, 'json'); + $denormalized = $this->serializer->denormalize($data, $type, 'json'); + $eventName = $this->stringValue($meta, 'event_name'); - return match ($type) { - Subscription::class => new SubscriptionRemoteEvent( - Ulid::fromString($payload['meta']['custom_data']['subscription_id']), - $data, - Event::from($payload['meta']['event_name']), + if ($denormalized instanceof Subscription) { + return new SubscriptionRemoteEvent( + Ulid::fromString($subscriptionId), + $denormalized, + Event::from($eventName), $payload, - ), - SubscriptionInvoice::class => new SubscriptionPaymentRemoteEvent( - Ulid::fromString($payload['meta']['custom_data']['subscription_id']), - $data, - Event::from($payload['meta']['event_name']), + ); + } + + if ($denormalized instanceof SubscriptionInvoice) { + return new SubscriptionPaymentRemoteEvent( + Ulid::fromString($subscriptionId), + $denormalized, + Event::from($eventName), $payload, - ), - default => throw new ParseException(sprintf('Unsupported type: %s', $type)), - }; + ); + } + + throw new ParseException(sprintf('Unsupported type: %s', $type)); + } + + /** + * @param array $data + * + * @return array + */ + private function arrayValue(array $data, string $key): array + { + $value = $data[$key] ?? null; + + if (! is_array($value)) { + throw new ParseException(sprintf('Expected array at key "%s".', $key)); + } + + $result = []; + + foreach ($value as $childKey => $childValue) { + $result[(string) $childKey] = $childValue; + } + + return $result; + } + + /** + * @param array $data + */ + private function stringValue(array $data, string $key): string + { + $value = $data[$key] ?? null; + + if (! is_string($value)) { + throw new ParseException(sprintf('Expected string at key "%s".', $key)); + } + + return $value; } /** diff --git a/src/Test/Traits/UsesFixturesTrait.php b/src/Test/Traits/UsesFixturesTrait.php index 839363f..9185b7a 100644 --- a/src/Test/Traits/UsesFixturesTrait.php +++ b/src/Test/Traits/UsesFixturesTrait.php @@ -94,6 +94,18 @@ protected static function loadFixture(string $path): array throw new RuntimeException(sprintf('Could not read fixture file "%s".', $fixturePath)); } - return (array) json_decode($content, true, flags: JSON_THROW_ON_ERROR); + $decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR); + + if (! is_array($decoded)) { + throw new RuntimeException(sprintf('Fixture file "%s" does not contain a JSON object.', $fixturePath)); + } + + $result = []; + + foreach ($decoded as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; } } diff --git a/src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php b/src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php index 86f0dbf..c3c2c75 100644 --- a/src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php +++ b/src/Tools/Rector/Rules/AddGenericTemplateExtendsRector.php @@ -43,7 +43,6 @@ use Rector\Comments\NodeDocBlock\DocBlockUpdater; use Rector\Rector\AbstractRector; use ReflectionClass; -use ReflectionException; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -238,12 +237,12 @@ public function refactor(Node $node): ?Node */ private function resolveGenericParentTemplateCount(string $parentFqn): ?int { - try { - $reflection = new ReflectionClass($parentFqn); - } catch (ReflectionException) { + if (! class_exists($parentFqn) && ! \interface_exists($parentFqn)) { return null; } + $reflection = new ReflectionClass($parentFqn); + $templateCount = $this->countOwnTemplateTags($reflection); if ($templateCount > 0) { return $templateCount; @@ -252,6 +251,9 @@ private function resolveGenericParentTemplateCount(string $parentFqn): ?int return self::GENERIC_PARENT_TEMPLATE_COUNT[$parentFqn] ?? null; } + /** + * @param ReflectionClass $reflection + */ private function countOwnTemplateTags(ReflectionClass $reflection): int { $docComment = $reflection->getDocComment(); @@ -259,17 +261,22 @@ private function countOwnTemplateTags(ReflectionClass $reflection): int return 0; } - return preg_match_all('/^\s*\*\s*@template(?:-covariant|-contravariant)?\s+[A-Za-z_]\w*/m', $docComment); + $count = preg_match_all('/^\s*\*\s*@template(?:-covariant|-contravariant)?\s+[A-Za-z_]\w*/m', $docComment); + if ($count === false) { + return 0; + } + + return $count; } private function findRootGenericAncestor(string $parentFqn): ?string { - try { - $reflection = new ReflectionClass($parentFqn); - } catch (ReflectionException) { + if (! class_exists($parentFqn) && ! \interface_exists($parentFqn)) { return null; } + $reflection = new ReflectionClass($parentFqn); + while ($reflection !== false) { if (isset(self::GENERIC_PARENT_TEMPLATE_COUNT[$reflection->getName()])) { return $reflection->getName(); @@ -501,7 +508,14 @@ private function extractArrayShapeItemFromAddCall(MethodCall $call): ArrayShapeI $fieldName = $nameArg->value->value; $typeArg = $call->args[1] ?? null; + if (! $typeArg instanceof Arg) { + $typeArg = null; + } + $optionsArg = $call->args[2] ?? null; + if (! $optionsArg instanceof Arg) { + $optionsArg = null; + } if ($this->isFieldUnmapped($optionsArg)) { return null; diff --git a/src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php b/src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php index 461346e..f2ae2d4 100644 --- a/src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php +++ b/src/Tools/Rector/Rules/EnforcePlatformEntityRepositoryRector.php @@ -25,7 +25,6 @@ use Rector\Comments\NodeDocBlock\DocBlockUpdater; use Rector\Rector\AbstractRector; use ReflectionClass; -use ReflectionException; use SolidWorx\Platform\PlatformBundle\Repository\EntityRepository as PlatformEntityRepository; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; @@ -144,10 +143,6 @@ private function fixStaleExtendsAnnotation(Class_ $node): void continue; } - if (! $value->type instanceof GenericTypeNode) { - continue; - } - $typeName = $value->type->type->name; if (! \in_array($typeName, self::DOCTRINE_SHORT_NAMES, true)) { continue; @@ -178,12 +173,12 @@ private function hasPlatformRepositoryInChain(string $classFqn): bool return true; } - try { - $reflection = new ReflectionClass($classFqn); - } catch (ReflectionException) { + if (! class_exists($classFqn) && ! \interface_exists($classFqn)) { return false; } + $reflection = new ReflectionClass($classFqn); + while ($reflection = $reflection->getParentClass()) { if ($reflection->getName() === PlatformEntityRepository::class) { return true; diff --git a/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php b/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php index 24d56aa..d57dfa8 100644 --- a/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php +++ b/tests/Bundle/PlatformBundle/Config/Builder/PlatformConfigBuilderTest.php @@ -32,8 +32,8 @@ public function testDefaultNameAndVersion(): void { $result = PlatformConfigBuilder::create()->build(); - self::assertSame('SolidWorx Platform', $result['platform']['name']); - self::assertSame('1.0.0', $result['platform']['version']); + self::assertSame('SolidWorx Platform', self::section($result, 'platform')['name']); + self::assertSame('1.0.0', self::section($result, 'platform')['version']); } public function testNameAndVersionCanBeOverridden(): void @@ -43,8 +43,8 @@ public function testNameAndVersionCanBeOverridden(): void ->version('2.5.0') ->build(); - self::assertSame('My App', $result['platform']['name']); - self::assertSame('2.5.0', $result['platform']['version']); + self::assertSame('My App', self::section($result, 'platform')['name']); + self::assertSame('2.5.0', self::section($result, 'platform')['version']); } public function testUserModelAppearsInBuild(): void @@ -53,13 +53,13 @@ public function testUserModelAppearsInBuild(): void ->userModel('App\Entity\User') ->build(); - self::assertSame('App\Entity\User', $result['platform']['models']['user']); + self::assertSame('App\Entity\User', self::section($result, 'platform', 'models')['user']); } public function testModelsAbsentWhenNotSet(): void { $result = PlatformConfigBuilder::create()->build(); - self::assertArrayNotHasKey('models', $result['platform']); + self::assertArrayNotHasKey('models', self::section($result, 'platform')); } public function testEnableUtcDateAppearsInBuild(): void @@ -68,13 +68,13 @@ public function testEnableUtcDateAppearsInBuild(): void ->enableUtcDate(true) ->build(); - self::assertTrue($result['platform']['doctrine']['types']['enable_utc_date']); + self::assertTrue(self::section($result, 'platform', 'doctrine', 'types')['enable_utc_date']); } public function testDoctrineAbsentWhenUtcDateNotSet(): void { $result = PlatformConfigBuilder::create()->build(); - self::assertArrayNotHasKey('doctrine', $result['platform']); + self::assertArrayNotHasKey('doctrine', self::section($result, 'platform')); } public function testWithSaasConfigInjectsUnderSaasKey(): void @@ -88,13 +88,13 @@ public function testWithSaasConfigInjectsUnderSaasKey(): void ]; $result = PlatformConfigBuilder::create()->withSaasConfig($saas)->build(); - self::assertSame($saas, $result['platform']['saas']); + self::assertSame($saas, self::section($result, 'platform')['saas']); } public function testSaasAbsentWhenNotSet(): void { $result = PlatformConfigBuilder::create()->build(); - self::assertArrayNotHasKey('saas', $result['platform']); + self::assertArrayNotHasKey('saas', self::section($result, 'platform')); } public function testWithUiConfigInjectsUnderUiKey(): void @@ -104,13 +104,13 @@ public function testWithUiConfigInjectsUnderUiKey(): void ]; $result = PlatformConfigBuilder::create()->withUiConfig($ui)->build(); - self::assertSame($ui, $result['platform']['ui']); + self::assertSame($ui, self::section($result, 'platform')['ui']); } public function testUiAbsentWhenNotSet(): void { $result = PlatformConfigBuilder::create()->build(); - self::assertArrayNotHasKey('ui', $result['platform']); + self::assertArrayNotHasKey('ui', self::section($result, 'platform')); } public function testSecurityBuilderChainReturnsParent(): void @@ -121,4 +121,23 @@ public function testSecurityBuilderChainReturnsParent(): void self::assertInstanceOf(SecurityConfigBuilder::class, $securityBuilder); self::assertSame($builder, $securityBuilder->end()); } + + /** + * Walk a nested key path, asserting each step is an array, and return the sub-array. + * + * @param array $result + * @return array + */ + private static function section(array $result, string ...$keys): array + { + $current = $result; + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $value = $current[$key]; + self::assertIsArray($value); + $current = $value; + } + + return $current; + } } diff --git a/tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php b/tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php index 0946c1c..412a84e 100644 --- a/tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php +++ b/tests/Bundle/PlatformBundle/Config/SchemaGeneratorTest.php @@ -54,8 +54,8 @@ public function testGenerateHasPlatformProperty(): void $generator = new SchemaGenerator([]); $schema = $generator->generate(); - self::assertArrayHasKey('platform', $schema['properties']); - self::assertSame('object', $schema['properties']['platform']['type']); + self::assertArrayHasKey('platform', self::section($schema, 'properties')); + self::assertSame('object', self::section($schema, 'properties', 'platform')['type']); } public function testGenerateAllowsAdditionalTopLevelProperties(): void @@ -71,7 +71,7 @@ public function testGeneratePlatformDisallowsAdditionalProperties(): void $generator = new SchemaGenerator([]); $schema = $generator->generate(); - self::assertFalse($schema['properties']['platform']['additionalProperties']); + self::assertFalse(self::section($schema, 'properties', 'platform')['additionalProperties']); } public function testEmptyConfigurationsProducesEmptyPlatformProperties(): void @@ -79,7 +79,7 @@ public function testEmptyConfigurationsProducesEmptyPlatformProperties(): void $generator = new SchemaGenerator([]); $schema = $generator->generate(); - self::assertSame([], $schema['properties']['platform']['properties']); + self::assertSame([], self::section($schema, 'properties', 'platform')['properties']); } public function testRootSectionKeyMergesChildrenIntoPlatformProperties(): void @@ -94,7 +94,7 @@ public function testRootSectionKeyMergesChildrenIntoPlatformProperties(): void $generator = new SchemaGenerator([$config]); $schema = $generator->generate(); - $properties = $schema['properties']['platform']['properties']; + $properties = self::section($schema, 'properties', 'platform', 'properties'); self::assertArrayHasKey('app_name', $properties); self::assertArrayHasKey('debug', $properties); self::assertArrayNotHasKey('root', $properties); @@ -111,9 +111,9 @@ public function testNonEmptySectionKeyAddsRootLevelProperty(): void $generator = new SchemaGenerator([$config]); $schema = $generator->generate(); - self::assertArrayHasKey('mymodule', $schema['properties']); - self::assertArrayNotHasKey('mymodule', $schema['properties']['platform']['properties']); - self::assertSame('object', $schema['properties']['mymodule']['type']); + self::assertArrayHasKey('mymodule', self::section($schema, 'properties')); + self::assertArrayNotHasKey('mymodule', self::section($schema, 'properties', 'platform', 'properties')); + self::assertSame('object', self::section($schema, 'properties', 'mymodule')['type']); } public function testScalarNodeGeneratesStringType(): void @@ -125,7 +125,7 @@ public function testScalarNodeGeneratesStringType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['title']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'title'); self::assertSame('string', $prop['type']); } @@ -139,7 +139,7 @@ public function testNullableScalarNodeGeneratesStringNullType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['template']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'template'); self::assertSame(['string', 'null'], $prop['type']); } @@ -153,7 +153,7 @@ public function testBooleanNodeGeneratesBooleanType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['enabled']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'enabled'); self::assertSame('boolean', $prop['type']); } @@ -167,7 +167,7 @@ public function testIntegerNodeGeneratesIntegerType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['count']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'count'); self::assertSame('integer', $prop['type']); } @@ -181,7 +181,7 @@ public function testFloatNodeGeneratesNumberType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['ratio']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'ratio'); self::assertSame('number', $prop['type']); } @@ -195,7 +195,7 @@ public function testEnumNodeGeneratesStringTypeWithEnumValues(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['color']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'color'); self::assertSame('string', $prop['type']); self::assertSame(['red', 'green', 'blue'], $prop['enum']); @@ -210,7 +210,7 @@ public function testVariableNodeGeneratesNoTypeConstraint(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['anything']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'anything'); self::assertArrayNotHasKey('type', $prop); } @@ -229,11 +229,11 @@ public function testArrayNodeGeneratesObjectType(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $nested = $schema['properties']['platform']['properties']['nested']; + $nested = self::section($schema, 'properties', 'platform', 'properties', 'nested'); self::assertSame('object', $nested['type']); self::assertArrayHasKey('properties', $nested); - self::assertArrayHasKey('key', $nested['properties']); + self::assertArrayHasKey('key', self::section($nested, 'properties')); } public function testArrayNodeSetsAdditionalPropertiesToFalse(): void @@ -250,7 +250,7 @@ public function testArrayNodeSetsAdditionalPropertiesToFalse(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $options = $schema['properties']['platform']['properties']['options']; + $options = self::section($schema, 'properties', 'platform', 'properties', 'options'); self::assertFalse($options['additionalProperties']); } @@ -269,11 +269,11 @@ public function testRequiredChildNodesPopulateRequiredArray(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $db = $schema['properties']['platform']['properties']['db']; + $db = self::section($schema, 'properties', 'platform', 'properties', 'db'); self::assertArrayHasKey('required', $db); - self::assertContains('host', $db['required']); - self::assertNotContains('port', $db['required']); + self::assertContains('host', self::section($db, 'required')); + self::assertNotContains('port', self::section($db, 'required')); } public function testPrototypedArrayNodeWithKeyAttributeGeneratesAdditionalProperties(): void @@ -292,7 +292,7 @@ public function testPrototypedArrayNodeWithKeyAttributeGeneratesAdditionalProper }); $schema = (new SchemaGenerator([$config]))->generate(); - $items = $schema['properties']['platform']['properties']['items']; + $items = self::section($schema, 'properties', 'platform', 'properties', 'items'); self::assertSame('object', $items['type']); self::assertArrayHasKey('additionalProperties', $items); @@ -310,7 +310,7 @@ public function testPrototypedArrayNodeWithoutKeyAttributeGeneratesItemsSchema() }); $schema = (new SchemaGenerator([$config]))->generate(); - $tags = $schema['properties']['platform']['properties']['tags']; + $tags = self::section($schema, 'properties', 'platform', 'properties', 'tags'); self::assertSame('array', $tags['type']); self::assertArrayHasKey('items', $tags); @@ -329,7 +329,7 @@ public function testNodeInfoPopulatesDescription(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['name']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'name'); self::assertSame('The application name.', $prop['description']); } @@ -343,7 +343,7 @@ public function testNodeWithoutInfoHasNoDescription(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['name']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'name'); self::assertArrayNotHasKey('description', $prop); } @@ -357,7 +357,7 @@ public function testDefaultValueIsIncludedInSchema(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['driver']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'driver'); self::assertSame('pdo_mysql', $prop['default']); } @@ -371,7 +371,7 @@ public function testBooleanDefaultFalseIsIncluded(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['active']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'active'); self::assertArrayHasKey('default', $prop); self::assertFalse($prop['default']); @@ -386,7 +386,7 @@ public function testRequiredNodeHasNoDefaultInSchema(): void }); $schema = (new SchemaGenerator([$config]))->generate(); - $prop = $schema['properties']['platform']['properties']['required_field']; + $prop = self::section($schema, 'properties', 'platform', 'properties', 'required_field'); self::assertArrayNotHasKey('default', $prop); } @@ -408,17 +408,17 @@ public function testMultipleConfigurationsAreMerged(): void $schema = (new SchemaGenerator([$config1, $config2]))->generate(); // Root section children go under platform: - self::assertArrayHasKey('name', $schema['properties']['platform']['properties']); + self::assertArrayHasKey('name', self::section($schema, 'properties', 'platform', 'properties')); // Non-empty section keys are root-level siblings of platform: - self::assertArrayHasKey('module', $schema['properties']); - self::assertArrayNotHasKey('module', $schema['properties']['platform']['properties']); + self::assertArrayHasKey('module', self::section($schema, 'properties')); + self::assertArrayNotHasKey('module', self::section($schema, 'properties', 'platform', 'properties')); } public function testWithRealPlatformConfiguration(): void { $generator = new SchemaGenerator([new PlatformConfiguration()]); $schema = $generator->generate(); - $props = $schema['properties']['platform']['properties']; + $props = self::section($schema, 'properties', 'platform', 'properties'); self::assertArrayHasKey('name', $props); self::assertArrayHasKey('version', $props); @@ -426,23 +426,23 @@ public function testWithRealPlatformConfiguration(): void self::assertArrayHasKey('doctrine', $props); self::assertArrayHasKey('models', $props); - self::assertSame('string', $props['name']['type']); - self::assertSame('SolidWorx Platform', $props['name']['default']); - self::assertSame('boolean', $props['security']['properties']['two_factor']['properties']['enabled']['type']); - self::assertSame(User::class, $props['models']['properties']['user']['default']); + self::assertSame('string', self::section($props, 'name')['type']); + self::assertSame('SolidWorx Platform', self::section($props, 'name')['default']); + self::assertSame('boolean', self::section($props, 'security', 'properties', 'two_factor', 'properties', 'enabled')['type']); + self::assertSame(User::class, self::section($props, 'models', 'properties', 'user')['default']); } public function testWithRealUiConfiguration(): void { $generator = new SchemaGenerator([new UiConfiguration()]); $schema = $generator->generate(); - $ui = $schema['properties']['ui']; + $ui = self::section($schema, 'properties', 'ui'); self::assertSame('object', $ui['type']); self::assertSame('UI / presentation configuration', $ui['description']); - self::assertArrayHasKey('icon_pack', $ui['properties']); - self::assertSame('tabler', $ui['properties']['icon_pack']['default']); - self::assertArrayHasKey('templates', $ui['properties']); + self::assertArrayHasKey('icon_pack', self::section($ui, 'properties')); + self::assertSame('tabler', self::section($ui, 'properties', 'icon_pack')['default']); + self::assertArrayHasKey('templates', self::section($ui, 'properties')); } public function testWithRealPlatformAndUiConfigurations(): void @@ -452,7 +452,7 @@ public function testWithRealPlatformAndUiConfigurations(): void new UiConfiguration(), ]); $schema = $generator->generate(); - $platformProps = $schema['properties']['platform']['properties']; + $platformProps = self::section($schema, 'properties', 'platform', 'properties'); // Root section keys (from PlatformConfiguration, key='') self::assertArrayHasKey('name', $platformProps); @@ -460,7 +460,26 @@ public function testWithRealPlatformAndUiConfigurations(): void // Non-empty section keys are root-level siblings self::assertArrayNotHasKey('ui', $platformProps); - self::assertArrayHasKey('ui', $schema['properties']); + self::assertArrayHasKey('ui', self::section($schema, 'properties')); + } + + /** + * Walk a nested key path, asserting each step is an array, and return the sub-array. + * + * @param array $schema + * @return array + */ + private static function section(array $schema, string ...$keys): array + { + $current = $schema; + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $value = $current[$key]; + self::assertIsArray($value); + $current = $value; + } + + return $current; } /** diff --git a/tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php b/tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php index e810a74..99265c6 100644 --- a/tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php +++ b/tests/Bundle/PlatformBundle/Response/RedirectResponseTest.php @@ -18,14 +18,13 @@ use SolidWorx\Platform\PlatformBundle\Enum\Flash; use SolidWorx\Platform\PlatformBundle\Response\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse as BaseRedirectResponse; -use function is_a; #[CoversClass(RedirectResponse::class)] final class RedirectResponseTest extends TestCase { public function testExtendsBaseRedirectResponse(): void { - self::assertTrue(is_a(RedirectResponse::class, BaseRedirectResponse::class, true)); + self::assertInstanceOf(BaseRedirectResponse::class, new RedirectResponse('/some-url')); } public function testStartsWithNoFlashes(): void diff --git a/tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php b/tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php index dc73f40..4fdc6ec 100644 --- a/tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php +++ b/tests/Bundle/Saas/Config/Builder/SaasConfigBuilderTest.php @@ -32,7 +32,7 @@ public function testSubscriptionEntityAppearsInBuild(): void ->subscriptionEntity('App\Entity\Subscription') ->build(); - self::assertSame('App\Entity\Subscription', $result['doctrine']['subscriptions']['entity']); + self::assertSame('App\Entity\Subscription', $this->arrayAt($result, 'doctrine', 'subscriptions')['entity']); } public function testTrialUserEntityAppearsInBuild(): void @@ -41,7 +41,7 @@ public function testTrialUserEntityAppearsInBuild(): void ->trialUserEntity('App\Entity\User') ->build(); - self::assertSame('App\Entity\User', $result['doctrine']['trial']['user_entity']); + self::assertSame('App\Entity\User', $this->arrayAt($result, 'doctrine', 'trial')['user_entity']); } public function testTableNameAppearsInBuild(): void @@ -51,14 +51,17 @@ public function testTableNameAppearsInBuild(): void ->tableName('trial', 'my_trials') ->build(); - self::assertSame('my_plans', $result['doctrine']['db_schema']['table_names']['plan']); - self::assertSame('my_trials', $result['doctrine']['db_schema']['table_names']['trial']); + $tableNames = $this->arrayAt($result, 'doctrine', 'db_schema', 'table_names'); + self::assertSame('my_plans', $tableNames['plan']); + self::assertSame('my_trials', $tableNames['trial']); } public function testTableNamesAbsentWhenNoneSet(): void { $result = SaasConfigBuilder::create()->build(); - self::assertArrayNotHasKey('db_schema', $result['doctrine'] ?? []); + $doctrine = $result['doctrine'] ?? []; + self::assertIsArray($doctrine); + self::assertArrayNotHasKey('db_schema', $doctrine); } public function testLemonSqueezyAppearsWhenSet(): void @@ -67,10 +70,11 @@ public function testLemonSqueezyAppearsWhenSet(): void ->lemonSqueezy('key_123', 'secret_abc', 'store_xyz') ->build(); - self::assertTrue($result['integration']['lemon_squeezy']['enabled']); - self::assertSame('key_123', $result['integration']['lemon_squeezy']['api_key']); - self::assertSame('secret_abc', $result['integration']['lemon_squeezy']['webhook_secret']); - self::assertSame('store_xyz', $result['integration']['lemon_squeezy']['store_id']); + $lemonSqueezy = $this->arrayAt($result, 'integration', 'lemon_squeezy'); + self::assertTrue($lemonSqueezy['enabled']); + self::assertSame('key_123', $lemonSqueezy['api_key']); + self::assertSame('secret_abc', $lemonSqueezy['webhook_secret']); + self::assertSame('store_xyz', $lemonSqueezy['store_id']); } public function testFeaturesAccumulateAcrossCalls(): void @@ -86,9 +90,10 @@ public function testFeaturesAccumulateAcrossCalls(): void ]) ->build(); - self::assertCount(2, $result['features']); - self::assertArrayHasKey('api_calls', $result['features']); - self::assertArrayHasKey('uploads', $result['features']); + $features = $this->arrayAt($result, 'features'); + self::assertCount(2, $features); + self::assertArrayHasKey('api_calls', $features); + self::assertArrayHasKey('uploads', $features); } public function testPaymentBuilderChainReturnsParent(): void @@ -108,6 +113,26 @@ public function testPaymentReturnRouteAppearsInBuild(): void ->end() ->build(); - self::assertSame('app_payment_success', $result['payment']['return_route']); + self::assertSame('app_payment_success', $this->arrayAt($result, 'payment')['return_route']); + } + + /** + * Navigate a nested config array, asserting each intermediate step is an array. + * + * @param array $config + * @return array + */ + private function arrayAt(array $config, string ...$keys): array + { + $current = $config; + + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $value = $current[$key]; + self::assertIsArray($value); + $current = $value; + } + + return $current; } } diff --git a/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php b/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php index 66d0805..38a0387 100644 --- a/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php +++ b/tests/Bundle/Saas/Feature/PlanFeatureManagerTest.php @@ -243,7 +243,7 @@ public function findOneByPlanAndKey(Plan $plan, string $featureKey): ?PlanFeatur public function findByPlans(array $plans): array { - return $this->planFeatures; + return array_values($this->planFeatures); } /** @@ -253,6 +253,14 @@ public function findByFeatureKey(string $featureKey): array { return array_filter($this->planFeatures, fn (PlanFeature $f): bool => $f->getFeatureKey() === $featureKey); } + + public function save(PlanFeature $entity, bool $flush = true): void + { + } + + public function remove(PlanFeature $entity, bool $flush = true): void + { + } }; $subscriptionProvider = new class($subscription) implements SubscriptionProviderInterface { diff --git a/tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php b/tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php index bc9ee6d..dff9e08 100644 --- a/tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php +++ b/tests/Bundle/Saas/RemoteEvent/LemonSqueezyWebhookConsumerTest.php @@ -51,7 +51,7 @@ final class LemonSqueezyWebhookConsumerTest extends TestCase * Tests that the consumer correctly dispatches events based on the remote event type. * * @param RemoteEvent $remoteEvent The remote event to consume - * @param class-string $expectedEventClass The expected event class to be dispatched + * @param class-string $expectedEventClass The expected event class to be dispatched * @throws Exception */ #[DataProvider('eventProvider')] diff --git a/tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php b/tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php index 380c49a..3728b2c 100644 --- a/tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php +++ b/tests/Bundle/Ui/Config/Builder/UiConfigBuilderTest.php @@ -25,8 +25,8 @@ public function testDefaultsAreApplied(): void $result = UiConfigBuilder::create()->build(); self::assertSame('tabler', $result['icon_pack']); - self::assertSame('@Ui/Layout/base.html.twig', $result['templates']['base']); - self::assertSame('@Ui/Security/login.html.twig', $result['templates']['login']); + self::assertSame('@Ui/Layout/base.html.twig', self::section($result, 'templates')['base']); + self::assertSame('@Ui/Security/login.html.twig', self::section($result, 'templates')['login']); } public function testIconPackCanBeOverridden(): void @@ -40,14 +40,14 @@ public function testBaseTemplateCanBeOverridden(): void { $result = UiConfigBuilder::create()->baseTemplate('@App/layout/base.html.twig')->build(); - self::assertSame('@App/layout/base.html.twig', $result['templates']['base']); + self::assertSame('@App/layout/base.html.twig', self::section($result, 'templates')['base']); } public function testLoginTemplateCanBeOverridden(): void { $result = UiConfigBuilder::create()->loginTemplate('@App/security/login.html.twig')->build(); - self::assertSame('@App/security/login.html.twig', $result['templates']['login']); + self::assertSame('@App/security/login.html.twig', self::section($result, 'templates')['login']); } public function testTemplatesAreNestedCorrectly(): void @@ -58,7 +58,26 @@ public function testTemplatesAreNestedCorrectly(): void ->build(); self::assertArrayHasKey('templates', $result); - self::assertArrayHasKey('base', $result['templates']); - self::assertArrayHasKey('login', $result['templates']); + self::assertArrayHasKey('base', self::section($result, 'templates')); + self::assertArrayHasKey('login', self::section($result, 'templates')); + } + + /** + * Walk a nested key path, asserting each step is an array, and return the sub-array. + * + * @param array $result + * @return array + */ + private static function section(array $result, string ...$keys): array + { + $current = $result; + foreach ($keys as $key) { + self::assertArrayHasKey($key, $current); + $value = $current[$key]; + self::assertIsArray($value); + $current = $value; + } + + return $current; } } From 7c1fb6be1b150dcae426b9274b8662272c6365c4 Mon Sep 17 00:00:00 2001 From: Pierre du Plessis Date: Thu, 18 Jun 2026 22:29:55 +0300 Subject: [PATCH 2/5] Fix PHPStan extension-rule errors to keep baseline empty Resolve the strict-rules, deprecation, symfony and phpunit extension errors that surface in CI (and were previously hidden by a stale local result cache), so the empty baseline holds under the full ruleset: - keep override params contravariant (PlanRepository::find, LemonSqueezyPayloadConverter::convert, Rector refactor() rules, LoginType/TwoFactorVerifyType::buildForm) and narrow via guards - add generics to form types (@extends AbstractType) and the 2FA component (FormInterface) - type Kernel::registerBundles via the non-deprecated Bundle class - replace short ternary, mixed/int|false booleans and a useless cast - rename getBackUpCodes() to match the interface; use getFile() - replace always-true assertInstanceOf checks with real assertions --- .../Platform/Form/Type/Security/LoginType.php | 19 ++++++++++++----- .../Type/Security/TwoFactorVerifyType.php | 18 +++++++++++----- .../Platform/Form/Type/TextEditorType.php | 1 + src/Bundle/Platform/Kernel.php | 16 ++++++++++++-- src/Bundle/Platform/Menu/Provider.php | 3 ++- .../TwoFactor/Traits/UserTwoFactor.php | 4 ++-- .../Twig/Components/Security/TwoFactor.php | 4 ++-- .../Command/SubscriptionListCommand.php | 9 +++++--- src/Bundle/Saas/Repository/PlanRepository.php | 20 +++++++++--------- .../LemonSqueezyPayloadConverter.php | 21 ++++++++++++++++++- .../Rules/AddGenericMethodPhpDocRector.php | 13 ++++++------ .../Rules/AddGenericTemplateExtendsRector.php | 7 ++++--- .../EnforcePlatformEntityRepositoryRector.php | 10 +++++---- .../Builder/PlatformConfigBuilderTest.php | 2 -- .../Response/RedirectResponseTest.php | 8 ++++--- .../Config/Builder/SaasConfigBuilderTest.php | 2 -- 16 files changed, 106 insertions(+), 51 deletions(-) diff --git a/src/Bundle/Platform/Form/Type/Security/LoginType.php b/src/Bundle/Platform/Form/Type/Security/LoginType.php index c15c079..e4226f4 100644 --- a/src/Bundle/Platform/Form/Type/Security/LoginType.php +++ b/src/Bundle/Platform/Form/Type/Security/LoginType.php @@ -13,6 +13,7 @@ namespace SolidWorx\Platform\PlatformBundle\Form\Type\Security; +use InvalidArgumentException; use Override; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EmailType; @@ -21,17 +22,25 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Email; use Symfony\Component\Validator\Constraints\NotBlank; +use function is_string; +/** + * @extends AbstractType + */ final class LoginType extends AbstractType { - /** - * @param array{username_parameter: string, password_parameter: string} $options - */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { + $usernameParameter = $options['username_parameter']; + $passwordParameter = $options['password_parameter']; + + if (! is_string($usernameParameter) || ! is_string($passwordParameter)) { + throw new InvalidArgumentException('The "username_parameter" and "password_parameter" options must be strings.'); + } + $builder - ->add($options['username_parameter'], EmailType::class, [ + ->add($usernameParameter, EmailType::class, [ 'label' => 'Email address', 'placeholder' => 'your@email.com', 'required' => true, @@ -47,7 +56,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], ]); - $builder->add($options['password_parameter'], PasswordType::class, [ + $builder->add($passwordParameter, PasswordType::class, [ 'label' => 'Password', 'required' => true, 'attr' => [ diff --git a/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php b/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php index a42525c..44e12a2 100644 --- a/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php +++ b/src/Bundle/Platform/Form/Type/Security/TwoFactorVerifyType.php @@ -13,6 +13,7 @@ namespace SolidWorx\Platform\PlatformBundle\Form\Type\Security; +use InvalidArgumentException; use Override; use SolidWorx\Platform\PlatformBundle\Validator\Constraint\TwoFactorCode; use Symfony\Component\Form\AbstractType; @@ -21,15 +22,22 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\NotBlank; +use function is_string; +/** + * @extends AbstractType + */ final class TwoFactorVerifyType extends AbstractType { - /** - * @param array{secret: string} $options - */ #[Override] public function buildForm(FormBuilderInterface $builder, array $options): void { + $secret = $options['secret']; + + if (! is_string($secret)) { + throw new InvalidArgumentException('The "secret" option must be a string.'); + } + $builder ->add('code', NumberType::class, [ 'label' => 'Enter the verification code generated by your mobile application.', @@ -44,11 +52,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], 'constraints' => [ new NotBlank(), - new TwoFactorCode(secret: $options['secret']), + new TwoFactorCode(secret: $secret), ], ]) ->add('secret', HiddenType::class, [ - 'data' => $options['secret'], + 'data' => $secret, ]); } diff --git a/src/Bundle/Platform/Form/Type/TextEditorType.php b/src/Bundle/Platform/Form/Type/TextEditorType.php index b5da04a..b569e43 100644 --- a/src/Bundle/Platform/Form/Type/TextEditorType.php +++ b/src/Bundle/Platform/Form/Type/TextEditorType.php @@ -34,6 +34,7 @@ * The field always renders a real `