Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ includes:
- phpstan-baseline.neon

parameters:
level: 6
level: 7
paths:
- src/
excludePaths:
Expand Down
2 changes: 1 addition & 1 deletion src/Config/Actions.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public function remove(string $pageName, string $actionName): self
public function reorder(string $pageName, array $orderedActionNames): self
{
$newActionOrder = [];
$currentActions = $this->dto->getActions();
$currentActions = $this->dto->getActionList();
foreach ($orderedActionNames as $actionName) {
if (!\array_key_exists($actionName, $currentActions[$pageName])) {
throw new \InvalidArgumentException(sprintf('The "%s" action does not exist in the "%s" page, so you cannot set its order.', $actionName, $pageName));
Expand Down
7 changes: 5 additions & 2 deletions src/Config/Crud.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,18 +260,21 @@ public function setDecimalSeparator(string $separator): self
*/
public function setDefaultSort(array $sortFieldsAndOrder): self
{
$sortFieldsAndOrder = array_map('strtoupper', $sortFieldsAndOrder);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is small phpstan issue with array_map.

PHPStan is loosing some informations about sortFieldsAndOrder ; I fixed it on PHPStan side. But anyway, I feel like it's better to include the strtoupper in the foreach in order to have one loop instead of two.

$defaultSort = [];
foreach ($sortFieldsAndOrder as $sortField => $sortOrder) {
$sortOrder = strtoupper($sortOrder);
if (!\in_array($sortOrder, [SortOrder::ASC, SortOrder::DESC], true)) {
throw new \InvalidArgumentException(sprintf('The sort order can be only "%s" or "%s", "%s" given.', SortOrder::ASC, SortOrder::DESC, $sortOrder));
}

if (!\is_string($sortField)) {
throw new \InvalidArgumentException(sprintf('The keys of the array that defines the default sort must be strings with the field names, but the given "%s" value is a "%s".', $sortField, \gettype($sortField)));
}

$defaultSort[$sortField] = $sortOrder;
}

$this->dto->setDefaultSort($sortFieldsAndOrder);
$this->dto->setDefaultSort($defaultSort);

return $this;
}
Expand Down
42 changes: 42 additions & 0 deletions src/Dto/ActionConfigDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public function __clone()
}
}

/**
* @deprecated since 4.25.0 and it will be removed in EasyAdmin 5.0.0.
*/
public function setPageName(?string $pageName): void
{
$this->pageName = $pageName;
Expand Down Expand Up @@ -109,16 +112,55 @@ public function disableActions(array $actionNames): void

/**
* @return ActionCollection|array<string,array<string,ActionDto>>
*
* @deprecated since 4.25.0 and it will be removed in EasyAdmin 5.0.0. Use `getPageActions` or `getActionList` instead.
*/
public function getActions(): ActionCollection|array
{
trigger_deprecation(
'easycorp/easyadmin-bundle',
'4.25.0',
'Calling "%s" is deprecated and will be removed in 5.0.0. Use `getPageActions` or `getActionList` instead.',
__METHOD__,
);

return null === $this->pageName ? $this->actions : ActionCollection::new($this->actions[$this->pageName]);
}

public function getPageActions(string $pageName): ActionCollection
{
return ActionCollection::new($this->actions[$pageName]);
}

/**
* @return array<string,array<string,ActionDto>>
*/
public function getActionList(): array
{
return $this->actions;
}

/**
* @param array<string, ActionDto> $newActions
*
* @deprecated since 4.25.0 and it will be removed in EasyAdmin 5.0.0. Use `setPageActions` instead.
*/
public function setActions(string $pageName, array $newActions): void
{
trigger_deprecation(
'easycorp/easyadmin-bundle',
'4.25.0',
'Calling "%s" is deprecated and will be removed in 5.0.0. Use `setPageActions` instead.',
__METHOD__,
);

$this->actions[$pageName] = $newActions;
}

/**
* @param array<string, ActionDto> $newActions
*/
public function setPageActions(string $pageName, array $newActions): void
{
$this->actions[$pageName] = $newActions;
}
Expand Down
1 change: 1 addition & 0 deletions src/Dto/FieldLayoutDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public function getFields(): array
*/
public function getFieldsInTab(string $tabUniqueId): array
{
/** @phpstan-ignore-next-line return.type */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class is deprecated, let's avoid this issue.

return $this->fields[$tabUniqueId] ?? [];
}
}
6 changes: 5 additions & 1 deletion src/EventListener/AdminRouterSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ public function onKernelRequest(RequestEvent $event): void

// if this is a ugly URL from legacy EasyAdmin versions and the application
// uses pretty URLs, redirect to the equivalent pretty URL
if ($this->adminRouteGenerator->usesPrettyUrls() && null !== $entityFqcnOrCrudControllerFqcn = $request->query->get(EA::CRUD_CONTROLLER_FQCN)) {
if ($this->adminRouteGenerator->usesPrettyUrls()) {
/** @var class-string|null $entityFqcnOrCrudControllerFqcn */
$entityFqcnOrCrudControllerFqcn = $request->query->get(EA::CRUD_CONTROLLER_FQCN);
if (is_subclass_of($entityFqcnOrCrudControllerFqcn, CrudControllerInterface::class)) {
$crudControllerFqcn = $entityFqcnOrCrudControllerFqcn;
} else {
Expand Down Expand Up @@ -209,7 +211,9 @@ public function onKernelController(ControllerEvent $event): void

// if the request is related to a CRUD controller, change the controller to be executed
if (null !== $crudControllerInstance = $this->getCrudControllerInstance($request)) {
/** @var callable $symfonyControllerFqcnCallable */
$symfonyControllerFqcnCallable = [$crudControllerInstance, $request->attributes->get(EA::CRUD_ACTION) ?? $request->query->get(EA::CRUD_ACTION)];
/** @var callable $symfonyControllerStringCallable */
$symfonyControllerStringCallable = [$crudControllerInstance::class, $request->attributes->get(EA::CRUD_ACTION) ?? $request->query->get(EA::CRUD_ACTION)];

// this makes Symfony believe that another controller is being executed
Expand Down
6 changes: 3 additions & 3 deletions src/Factory/ActionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public function processEntityActions(EntityDto $entityDto, ActionConfigDto $acti
{
$currentPage = $this->adminContextProvider->getContext()->getCrud()->getCurrentPage();
$entityActions = [];
foreach ($actionsDto->getActions()->all() as $actionDto) {
foreach ($actionsDto->getPageActions($currentPage)->all() as $actionDto) {
if (!$actionDto->isEntityAction()) {
continue;
}
Expand Down Expand Up @@ -79,7 +79,7 @@ public function processGlobalActions(?ActionConfigDto $actionsDto = null): Actio

$currentPage = $this->adminContextProvider->getContext()->getCrud()->getCurrentPage();
$globalActions = [];
foreach ($actionsDto->getActions()->all() as $actionDto) {
foreach ($actionsDto->getPageActions($currentPage)->all() as $actionDto) {
if (!$actionDto->isGlobalAction() && !$actionDto->isBatchAction()) {
continue;
}
Expand Down Expand Up @@ -171,7 +171,7 @@ private function processActionLabel(ActionDto $actionDto, ?EntityDto $entityDto,
return;
}

if (\is_callable($label) && $label instanceof \Closure) {
if (!\is_string($label) && !$label instanceof TranslatableInterface && \is_callable($label)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bug existed for array-callable ; I added a test.

$label = \call_user_func_array($label, array_filter([$entityDto?->getInstance()], static fn ($item): bool => null !== $item));

if (!\is_string($label) && !$label instanceof TranslatableInterface) {
Expand Down
2 changes: 1 addition & 1 deletion src/Factory/AdminContextFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ private function getCrudDto(CrudControllerRegistry $crudControllers, DashboardCo
return $crudDto;
}

private function getActionConfig(DashboardControllerInterface $dashboardController, ?CrudControllerInterface $crudController, ?string $pageName): ActionConfigDto
private function getActionConfig(DashboardControllerInterface $dashboardController, ?CrudControllerInterface $crudController, ?string $pageName = null): ActionConfigDto
{
if (null === $crudController) {
return new ActionConfigDto();
Expand Down
11 changes: 9 additions & 2 deletions src/Factory/FormLayoutFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormTabPaneCloseType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormTabPaneGroupCloseType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormTabPaneGroupOpenType;
use Stringable;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Uid\Ulid;

Expand Down Expand Up @@ -78,7 +79,10 @@ private function validateLayoutConfiguration(FieldCollection $fields): void
}

if ($theFirstFieldWhichIsATabOrColumn->isFormColumn() && $fieldDto->isFormTab()) {
throw new \InvalidArgumentException(sprintf('When using form columns, you can\'t define tabs inside columns (but you can define columns inside tabs). Move the tab "%s" outside any column.', $fieldDto->getLabel()));
Copy link
Contributor Author

@VincentLanglet VincentLanglet Jul 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and in multiple place, getLabel is considered as able to be casted to string, but this is not true.

TranslatableInterface does not extends Stringable. Only TranslatableMessage does.
And it's not possible to implement it for enum for instance.
But if someone use his own implementation, it will crash.

Not sure if this fix is enough, or you want to improve/extract logic somewhere else (like a TranslatableHelper::toString method) @javiereguiluz

$label = $fieldDto->getLabel();
$labelAsString = (\is_string($label) || $label instanceof \Stringable) ? (string) $label : '';

throw new \InvalidArgumentException(sprintf('When using form columns, you can\'t define tabs inside columns (but you can define columns inside tabs). Move the tab "%s" outside any column.', $labelAsString));
}
}
}
Expand Down Expand Up @@ -181,7 +185,9 @@ private function linearizeLayoutConfiguration(FieldCollection $fields): void

if ($fieldDto->isFormTab()) {
$isTabActive = 0 === \count($tabs);
$tabId = sprintf('tab-%s', $fieldDto->getLabel() ? $slugger->slug(strip_tags($fieldDto->getLabel()))->lower()->toString() : ++$tabsWithoutLabelCounter);
$label = $fieldDto->getLabel();
$labelAsString = (\is_string($label) || $label instanceof Stringable) ? (string) $label : '';
$tabId = sprintf('tab-%s', '' !== $labelAsString ? $slugger->slug(strip_tags($labelAsString))->lower()->toString() : ++$tabsWithoutLabelCounter);
$fieldDto->setCustomOption(FormField::OPTION_TAB_ID, $tabId);
$fieldDto->setCustomOption(FormField::OPTION_TAB_IS_ACTIVE, $isTabActive);

Expand Down Expand Up @@ -395,6 +401,7 @@ public static function createFromFieldDtos(?FieldCollection $fieldDtos): FieldLa
$tabs[$fieldDto->getUniqueId()] = $fieldDto;
} else {
if ($hasTabs) {
/** @phpstan-ignore-next-line offsetAccess.nonOffsetAccessible */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method is deprecated, let's just ignore the error

$fields[$currentTab->getUniqueId()][] = $fieldDto;
} else {
$fields[] = $fieldDto;
Expand Down
6 changes: 5 additions & 1 deletion src/Factory/MenuFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Dto\UserMenuDto;
use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGeneratorInterface;
use EasyCorp\Bundle\EasyAdminBundle\Security\Permission;
use Stringable;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator;
use function Symfony\Component\Translation\t;
Expand Down Expand Up @@ -134,7 +135,10 @@ private function generateMenuItemUrl(MenuItemDto $menuItemDto): string
$entityFqcn = $routeParameters[EA::ENTITY_FQCN] ?? null;
$crudControllerFqcn = $routeParameters[EA::CRUD_CONTROLLER_FQCN] ?? null;
if (null === $entityFqcn && null === $crudControllerFqcn) {
throw new \RuntimeException(sprintf('The CRUD menu item with label "%s" must define either the entity FQCN (using the third constructor argument) or the CRUD Controller FQCN (using the "setController()" method).', $menuItemDto->getLabel()));
$label = $menuItemDto->getLabel();
$labelAsString = (\is_string($label) || $label instanceof \Stringable) ? (string) $label : '';

throw new \RuntimeException(sprintf('The CRUD menu item with label "%s" must define either the entity FQCN (using the third constructor argument) or the CRUD Controller FQCN (using the "setController()" method).', $labelAsString));
}

// 1. if CRUD controller is defined, use it...
Expand Down
5 changes: 4 additions & 1 deletion src/Form/Type/CrudFormType.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormRowType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormTabPaneCloseType;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Layout\EaFormTabPaneOpenType;
use Stringable;
use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
Expand Down Expand Up @@ -94,7 +95,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
$metadata['label'] = $fieldDto->getLabel();
$metadata['help'] = $fieldDto->getHelp();
$metadata[FormField::OPTION_ICON] = $fieldDto->getCustomOption(FormField::OPTION_ICON);
$currentFormTab = (string) $fieldDto->getLabel();

$label = $fieldDto->getLabel();
$currentFormTab = (\is_string($label) || $label instanceof \Stringable) ? (string) $label : '';

// plain arrays are not enough for tabs because they are modified in the
// lifecycle of a form (e.g. add info about form errors). Use an ArrayObject instead.
Expand Down
5 changes: 4 additions & 1 deletion src/Form/Type/FileUploadType.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ public function configureOptions(OptionsResolver $resolver): void

$index = 1;
$pathInfo = pathinfo($filename);
while (file_exists($filename = sprintf('%s/%s_%d.%s', $pathInfo['dirname'], $pathInfo['filename'], $index, $pathInfo['extension']))) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dirname and extension might theorically not exists.

I handle the case.


$basePath = isset($pathInfo['dirname']) ? sprintf('%s/%s', $pathInfo['dirname'], $pathInfo['filename']) : $pathInfo['filename'];
$endPath = isset($pathInfo['extension']) ? '.'.$pathInfo['extension'] : '';
while (file_exists($filename = sprintf('%s_%d%s', $basePath, $index, $endPath))) {
++$index;
}

Expand Down
6 changes: 4 additions & 2 deletions src/Intl/IntlFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ public function formatNumber($number, array $attrs = [], string $style = 'decima
$formatter = $this->createNumberFormatter($locale, $style, $attrs);

$ret = $formatter->format($number, self::NUMBER_TYPES[$type]);
if (!\is_string($formatter->format($number, self::NUMBER_TYPES[$type]))) {
if (!\is_string($ret)) {
throw new RuntimeError('Unable to format the given number.');
}

Expand Down Expand Up @@ -261,14 +261,16 @@ private function createNumberFormatter(?string $locale, string $style, array $at

$value = self::NUMBER_PADDING_ATTRIBUTES[$value];
}

/** @var int|float $value */
$this->numberFormatters[$hash]->setAttribute(self::NUMBER_ATTRIBUTES[$name], $value);
}

/** @var string $value */
foreach ($textAttrs as $name => $value) {
$this->numberFormatters[$hash]->setTextAttribute(self::NUMBER_TEXT_ATTRIBUTES[$name], $value);
}

/** @var string $value */
foreach ($symbols as $name => $value) {
$this->numberFormatters[$hash]->setSymbol(self::NUMBER_SYMBOLS[$name], $value);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Maker/ClassMaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@ private function renderSkeleton(string $filePath, array $parameters): string
extract($parameters, \EXTR_SKIP);
include $filePath;

return ob_get_clean();
return (string) ob_get_clean();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can technically be false, but not in our case, let's just ignore this by casting it

}
}
25 changes: 15 additions & 10 deletions src/Menu/MenuItemMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ private function doMarkSelectedLegacyMenuItem(array $menuItems, Request $request

$menuItemQueryString = null === $menuItemDto->getLinkUrl() ? null : parse_url($menuItemDto->getLinkUrl(), \PHP_URL_QUERY);

/** @var array<string, mixed> $menuItemQueryParameters */
$menuItemQueryParameters = [];
if (\is_string($menuItemQueryString)) {
parse_str($menuItemQueryString, $menuItemQueryParameters);
Expand All @@ -114,7 +115,9 @@ private function doMarkSelectedLegacyMenuItem(array $menuItems, Request $request
// match that menu item for all actions (EDIT, NEW, etc.) of the same controller;
// this is not strictly correct, but backend users expect this behavior because it
// makes the sidebar menu more predictable and easier to use
/** @var class-string|null $menuItemController */
$menuItemController = $menuItemQueryParameters[EA::CRUD_CONTROLLER_FQCN] ?? null;
/** @var class-string|null $currentPageController */
$currentPageController = $currentPageQueryParameters[EA::CRUD_CONTROLLER_FQCN] ?? null;
$actionsLinkedInTheMenuForThisEntity = $controllersAndActionsLinkedInTheMenu[$currentPageController] ?? [];
$menuOnlyLinksToIndexActionOfThisEntity = $actionsLinkedInTheMenuForThisEntity === [Crud::PAGE_INDEX];
Expand Down Expand Up @@ -190,18 +193,20 @@ private function doMarkSelectedPrettyUrlsMenuItem(array $menuItems, Request $req

// remove host part from the menu item link URL
$urlParts = parse_url($menuItemDto->getLinkUrl());
$menuItemUrlWithoutHost = $urlParts['path'] ?? '';
if (\array_key_exists('query', $urlParts)) {
$menuItemUrlWithoutHost .= '?'.$urlParts['query'];
}
if (\array_key_exists('fragment', $urlParts)) {
$menuItemUrlWithoutHost .= '#'.$urlParts['fragment'];
}
if (false !== $urlParts) {
$menuItemUrlWithoutHost = $urlParts['path'] ?? '';
if (\array_key_exists('query', $urlParts)) {
$menuItemUrlWithoutHost .= '?'.$urlParts['query'];
}
if (\array_key_exists('fragment', $urlParts)) {
$menuItemUrlWithoutHost .= '#'.$urlParts['fragment'];
}

if ($menuItemUrlWithoutHost === $currentUrlWithoutHostAndWithNormalizedQueryString) {
$menuItemDto->setSelected(true);
if ($menuItemUrlWithoutHost === $currentUrlWithoutHostAndWithNormalizedQueryString) {
$menuItemDto->setSelected(true);

return $menuItems;
return $menuItems;
}
}
}

Expand Down
5 changes: 2 additions & 3 deletions src/Orm/EntityRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ private function addFilterClause(QueryBuilder $queryBuilder, SearchDto $searchDt
'value' => $submittedData,
];
}
/** @var array{comparison: string, value: mixed, value2?: mixed} $submittedData */

/** @var string $rootAlias */
$rootAlias = current($queryBuilder->getRootAliases());
Expand Down Expand Up @@ -328,7 +329,6 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
: $entityDto->getFqcn()
;

/** @var \ReflectionNamedType|\ReflectionUnionType|null $idClassType */
$idClassType = null;
$reflectionClass = new \ReflectionClass($entityFqcn);

Expand All @@ -342,8 +342,7 @@ private function getSearchablePropertiesConfig(QueryBuilder $queryBuilder, Searc
$reflectionClass = $reflectionClass->getParentClass();
}

if (null !== $idClassType) {
/** @var \ReflectionNamedType|\ReflectionUnionType $idClassType */
if ($idClassType instanceof \ReflectionNamedType) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReflectionUnionType::getName does not exists

$idClassName = $idClassType->getName();

if (class_exists($idClassName)) {
Expand Down
8 changes: 6 additions & 2 deletions src/Orm/EntityUpdater.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ public function __construct(PropertyAccessorInterface $propertyAccessor, Validat

public function updateProperty(EntityDto $entityDto, string $propertyName, mixed $value): void
{
if (!$this->propertyAccessor->isWritable($entityDto->getInstance(), $propertyName)) {
$entityInstance = $entityDto->getInstance();
if (null === $entityInstance) {
return;
}

if (!$this->propertyAccessor->isWritable($entityInstance, $propertyName)) {
throw new \RuntimeException(sprintf('The "%s" property of the "%s" entity is not writable.', $propertyName, $entityDto->getName()));
}

$entityInstance = $entityDto->getInstance();
$this->propertyAccessor->setValue($entityInstance, $propertyName, $value);

/** @var ConstraintViolationList $violations */
Expand Down
Loading