skip to Main Content

I am not sure why every time I change the PHP annotation (update the validation constraint #[Assert/Type], update operations in #[ApiResource]…), then send a new request to any api endpoint, it takes a lot of time to return the response (about 8s).

After that, the subsequent request’s response time returns to normal (about 500ms). It seems symfony/api platform rebuilds the whole cache each time there are any changes in PHP annotation and somehow this process takes a long time to finish.

I am using the latest Symfony 6.3 and Api platform 3.2.
For the web server I am using the built in symfony serve server.

This is my api_platform.yaml and env.local config

api_platform:
    title: Hello API Platform
    version: 1.0.0
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
        html: ['text/html']
        jsonhal: ['application/hal+json']
    docs_formats:
        jsonld: ['application/ld+json']
        jsonopenapi: ['application/vnd.openapi+json']
        html: ['text/html']
    defaults:
        pagination_items_per_page: 5
        pagination_client_items_per_page: true
        pagination_client_enabled: true
        collection:
            pagination:
                items_per_page_parameter_name: itemsPerPage
        stateless: true
        cache_headers:
            vary: ['Content-Type', 'Authorization', 'Origin']
        extra_properties:
            standard_put: true
            rfc_7807_compliant_errors: true
    event_listeners_backward_compatibility_layer: false
    keep_legacy_inflector: false
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
#  * .env                contains default values for the environment variables needed by the app
#  * .env.local          uncommitted file with local overrides
#  * .env.$APP_ENV       committed environment-specific defaults
#  * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration

###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=54e7442b72dbc7099aef4ae1aae2300b
###< symfony/framework-bundle ###

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
 DATABASE_URL="mysql://[email protected]:3306/api_platform?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
#DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=15&charset=utf8"
###< doctrine/doctrine-bundle ###

###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

This is the User entity

<?php

namespace AppEntity;

use ApiPlatformMetadataApiFilter;
use ApiPlatformMetadataApiResource;
use ApiPlatformMetadataDelete;
use ApiPlatformMetadataGet;
use ApiPlatformMetadataGetCollection;
use ApiPlatformMetadataPatch;
use ApiPlatformMetadataPost;
use ApiPlatformMetadataPut;
use ApiPlatformSerializerFilterPropertyFilter;
use AppEntityTraitsTimestamp;
use AppRepositoryUserRepository;
use DoctrineCommonCollectionsArrayCollection;
use DoctrineCommonCollectionsCollection;
use DoctrineDBALTypesTypes;
use DoctrineORMMapping as ORM;
use DoctrineORMMappingColumn;
use GedmoMappingAnnotation as Gedmo;
use GedmoMappingAnnotationTimestampable;
use SymfonyBridgeDoctrineValidatorConstraintsUniqueEntity;
use SymfonyComponentSecurityCoreUserPasswordAuthenticatedUserInterface;
use SymfonyComponentSecurityCoreUserUserInterface;
use SymfonyComponentSerializerAnnotationGroups;
use SymfonyComponentValidatorConstraints as Assert;
use SymfonyComponentValidatorConstraintsNotBlank;

#[ORMEntity(repositoryClass: UserRepository::class)]
#[ApiResource(
    operations: [
        new Get(normalizationContext: ['groups' => ['users:read', 'users:item:read']]),
        new GetCollection(),
        new Post(),
        new Put(),
        new Patch(),
        new Delete()
    ],
    normalizationContext: ['groups' => 'users:read'],
    denormalizationContext: ['groups' => 'users:write']
)]
#[UniqueEntity(fields: 'email', message: 'There is already an account with this email')]
#[UniqueEntity(fields: 'name', message: 'There is already an account with this name')]
#[ApiFilter(PropertyFilter::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    use Timestamp;

    /**
     * @var DateTime|null
     * @Timestampable(on="create")
     * @Column(type="datetime")
     */
    #[Timestampable(on: 'create')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['users:read'])]
    protected $createdAt;

    /**
     * @var DateTime|null
     * @GedmoTimestampable(on="update")
     * @ORMColumn(type="datetime")
     */
    #[Timestampable(on: 'update')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['users:read'])]
    protected $updatedAt;

    #[ORMId]
    #[ORMGeneratedValue]
    #[ORMColumn]
    private ?int $id = null;

    #[AssertType('string')]
    #[AssertEmail()]
    #[ORMColumn(length: 180, unique: true)]
    #[Groups(['users:read', 'users:write', 'treasure:item:read'])]
    #[NotBlank]
    private ?string $email = null;

    #[ORMColumn]
    private array $roles = [];

    /**
     * @var string The hashed password
     */
    #[ORMColumn]
    #[Groups(['users:write'])]
    #[NotBlank]
    private ?string $password = null;

    #[ORMColumn(length: 255)]
    #[Groups(['users:read', 'users:write', 'treasure:item:read'])]
    #[NotBlank]
    private ?string $name = null;

    #[ORMOneToMany(mappedBy: 'owner', targetEntity: DragonTreasure::class, cascade: ['persist'])]
    #[Groups(['users:read', 'users:write'])]
    #[AssertValid]
    private Collection $dragonTreasures;

    public function __construct()
    {
        $this->dragonTreasures = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): static
    {
        $this->email = $email;

        return $this;
    }

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): static
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    public function setPassword(string $password): static
    {
        $this->password = $password;

        return $this;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials(): void
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Collection<int, DragonTreasure>
     */
    public function getDragonTreasures(): Collection
    {
        return $this->dragonTreasures;
    }

    public function addDragonTreasure(DragonTreasure $dragonTreasure): static
    {
        if (!$this->dragonTreasures->contains($dragonTreasure)) {
            $this->dragonTreasures->add($dragonTreasure);
            $dragonTreasure->setOwner($this);
        }

        return $this;
    }

    public function removeDragonTreasure(DragonTreasure $dragonTreasure): static
    {
        if ($this->dragonTreasures->removeElement($dragonTreasure)) {
            // set the owning side to null (unless already changed)
            if ($dragonTreasure->getOwner() === $this) {
                $dragonTreasure->setOwner(null);
            }
        }

        return $this;
    }
}

This is DragonTreasure entity

<?php

namespace AppEntity;

use ApiPlatformDoctrineOrmFilterBooleanFilter;
use ApiPlatformDoctrineOrmFilterRangeFilter;
use ApiPlatformDoctrineOrmFilterSearchFilter;
use ApiPlatformMetadataApiFilter;
use ApiPlatformMetadataApiResource;
use ApiPlatformMetadataDelete;
use ApiPlatformMetadataGet;
use ApiPlatformMetadataGetCollection;
use ApiPlatformMetadataLink;
use ApiPlatformMetadataPatch;
use ApiPlatformMetadataPost;
use ApiPlatformMetadataPut;
use ApiPlatformSerializerFilterPropertyFilter;
use AppEntityTraitsTimestamp;
use AppRepositoryDragonTreasureRepository;
use CarbonCarbon;
use DoctrineDBALTypesTypes;
use DoctrineORMMapping as ORM;
use DoctrineORMMappingColumn;
use GedmoMappingAnnotation as Gedmo;
use GedmoMappingAnnotationTimestampable;
use SymfonyComponentSerializerAnnotationGroups;
use SymfonyComponentSerializerAnnotationSerializedName;
use SymfonyComponentValidatorConstraintsGreaterThanOrEqual;
use SymfonyComponentValidatorConstraintsLessThanOrEqual;
use SymfonyComponentValidatorConstraintsNotBlank;
use SymfonyComponentValidatorConstraintsNotNull;
use SymfonyComponentValidatorConstraintsType;
use SymfonyContractsServiceAttributeRequired;
use function SymfonyComponentStringu;

#[ApiResource(
    shortName: 'Treasure',
    description: 'Rare and valuable resources',
    operations: [
        new Get(normalizationContext: ['groups' => ['treasure:read', 'treasure:item:read']]),
        new GetCollection(),
        new Post(),
        new Put(),
        new Patch(),
        new Delete()
    ],
    formats: [
        'jsonld',
        'json',
        'html',
        'jsonhal',
        'csv' => 'text/csv'
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
    denormalizationContext: [
        'groups' => ['treasure:write']
    ],
    paginationItemsPerPage: 10
)]
#[ApiResource(
    uriTemplate: 'users/{user_id}/treasures.{_format}',
    shortName: 'Treasure',
    operations: [
        new GetCollection()
    ],
    uriVariables: [
        'user_id' => new Link(
            fromProperty: 'dragonTreasures', fromClass: User::class,
//            toProperty: 'owner'
        )
    ],
    normalizationContext: [
        'groups' => ['treasure:read']
    ],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(PropertyFilter::class)]
#[ORMEntity(repositoryClass: DragonTreasureRepository::class)]
#[ApiFilter(SearchFilter::class, properties: ['owner.name' => 'partial'])]
class DragonTreasure
{
    use Timestamp;

    /**
     * @var DateTime|null
     * @Timestampable(on="create")
     * @Column(type="datetime")
     */
    #[Timestampable(on: 'create')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $createdAt;

    /**
     * @var DateTime|null
     * @GedmoTimestampable(on="update")
     * @ORMColumn(type="datetime")
     */
    #[Timestampable(on: 'update')]
    #[Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(['treasure:read'])]
    protected $updatedAt;
    #[ORMId]
    #[ORMGeneratedValue]
    #[ORMColumn]
    #[Groups(['treasure:read'])]
    private ?int $id = null;

    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORMColumn(length: 255)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    #[NotBlank]
    #[NotNull]
    private ?string $name = null;

    #[Groups(['treasure:read', 'users:item:read'])]
    #[ORMColumn(type: Types::TEXT)]
    #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
    private ?string $description = null;

    /**
     * Value of the treasure
     */
    #[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
    #[ORMColumn]
    #[ApiFilter(RangeFilter::class)]
    #[GreaterThanOrEqual(0)]
    #[NotBlank]
    #[NotNull]
    #[Type('integer')]
    private ?int $value = 0;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORMColumn]
    #[GreaterThanOrEqual(0)]
    #[LessThanOrEqual(10)]
    #[NotBlank]
    #[NotNull]
    #[Type('numeric')]
    private ?int $coolFactor = 0;

    #[Groups(['treasure:read', 'treasure:write'])]
    #[ORMColumn]
    private ?bool $isPublished = false;

    #[Groups(['treasure:read'])]
    #[ORMManyToOne(inversedBy: 'dragonTreasures')]
    #[ORMJoinColumn(name: 'owner_id', onDelete: 'CASCADE')]
    #[ApiFilter(SearchFilter::class, 'exact')]
    private ?User $owner = null;

    public function __construct(string $name = null)
    {
        $this->name = $name;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): static
    {
        $this->name = $name;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getShortDescription(): ?string
    {
        return u($this->description)->truncate(10, '...');
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    #[Groups(['treasure:read'])]
    public function setDescription(string $description): static
    {
        $this->description = $description;

        return $this;
    }

    #[Groups(['treasure:read'])]
    public function getCreatedAtAgo(): ?string
    {
        return Carbon::parse($this->createdAt)->diffForHumans();
    }

    #[Groups(['treasure:write'])]
    #[SerializedName('description')]
    public function setTextDescription(string $description): static
    {
        $this->description = nl2br($description);

        return $this;
    }

    public function getValue(): ?int
    {
        return $this->value;
    }

    public function setValue(int $value): static
    {
        $this->value = $value;

        return $this;
    }

    public function getCoolFactor(): ?int
    {
        return $this->coolFactor;
    }

    public function setCoolFactor(int $coolFactor): static
    {
        $this->coolFactor = $coolFactor;

        return $this;
    }

    public function getIsPublished(): ?bool
    {
        return $this->isPublished;
    }

    public function setIsPublished(bool $isPublished): static
    {
        $this->isPublished = $isPublished;

        return $this;
    }

    public function getOwner(): ?User
    {
        return $this->owner;
    }

    public function setOwner(?User $owner): static
    {
        $this->owner = $owner;

        return $this;
    }
}

I would appreciate any advice you may have for me.

2

Answers


  1. For performance reasosns Symfony preprocesses and caches a lot of things, incl. annotation driven behavior, so any change in the data invalidates the cache and will force Symfony to rebuild it which simply can take a while, depending om your project. The 8 seconds observing is a noticeable delay, but I do not know what is your runtime environment so it’s hard to comment on that really. Also please note the built-in server is not meant for production use.

    Finally, you can manually manage the cache as there’re are multiple commands to help with that task, including rebuilding in prior starting up your application:

    $ bin/console cache:warmup
    

    See these for more:

    $ bin/console list cache
    $ bin/console list doctrine:cache
    
    Login or Signup to reply.
  2. A cache in front of a cache is often counter-productive.

    Assuming that Symphony reaches into MySQL, be aware that the database does much caching itself. This doubling of work may be slowing down the overall process.

    Also, Symphony may be caching far more than it needs.

    Try turning off all (or part) of Symphony’s caching. Trying the same in MySQL is unreasonable unless you are running out of RAM than you have. Swapping is terrible for performance; it is better to shrink chaches than to depend on Swapping for extra space.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search