Claude Code Plugins

Community-maintained marketplace

Feedback

symfony:api-platform-serialization

@MakFly/superpowers-symfony
14
0

Control API output with serialization groups, custom normalizers, and context-aware serialization in API Platform

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name symfony:api-platform-serialization
description Control API output with serialization groups, custom normalizers, and context-aware serialization in API Platform

API Platform Serialization

Serialization Groups

Basic Groups

<?php
// src/Entity/User.php

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['user:list']],
        ),
        new Get(
            normalizationContext: ['groups' => ['user:read']],
        ),
        new Post(
            denormalizationContext: ['groups' => ['user:create']],
        ),
        new Put(
            denormalizationContext: ['groups' => ['user:update']],
        ),
    ],
)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['user:list', 'user:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['user:list', 'user:read', 'user:create', 'user:update'])]
    private string $name;

    #[ORM\Column(length: 255, unique: true)]
    #[Groups(['user:read', 'user:create'])] // Not in list, not updatable
    private string $email;

    #[ORM\Column]
    #[Groups(['user:create'])] // Write-only
    private string $password;

    #[ORM\Column]
    #[Groups(['user:read'])] // Read-only, not in list
    private \DateTimeImmutable $createdAt;

    #[ORM\OneToMany(targetEntity: Post::class, mappedBy: 'author')]
    #[Groups(['user:read'])] // Only on detail view
    private Collection $posts;
}

Nested Serialization

<?php
// src/Entity/Post.php

#[ApiResource(
    normalizationContext: ['groups' => ['post:read']],
)]
class Post
{
    #[Groups(['post:read', 'user:read'])]
    private ?int $id = null;

    #[Groups(['post:read', 'user:read'])]
    private string $title;

    #[Groups(['post:read'])] // Full content only on post detail
    private string $content;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[Groups(['post:read'])]
    private User $author;
}

// src/Entity/User.php
class User
{
    // When user:read includes posts, only id and title are shown
    #[Groups(['user:read'])]
    private Collection $posts;
}

Custom Normalizers

Add Computed Fields

<?php
// src/Serializer/Normalizer/UserNormalizer.php

namespace App\Serializer\Normalizer;

use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class UserNormalizer implements NormalizerInterface
{
    public function __construct(
        #[Autowire(service: 'serializer.normalizer.object')]
        private NormalizerInterface $normalizer,
    ) {}

    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        /** @var User $object */
        $data = $this->normalizer->normalize($object, $format, $context);

        // Add computed fields
        $data['fullName'] = $object->getFirstName() . ' ' . $object->getLastName();
        $data['postCount'] = $object->getPosts()->count();
        $data['isVerified'] = $object->getVerifiedAt() !== null;

        return $data;
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof User;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [User::class => true];
    }
}

Conditional Serialization

<?php
// src/Serializer/Normalizer/PostNormalizer.php

namespace App\Serializer\Normalizer;

use App\Entity\Post;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class PostNormalizer implements NormalizerInterface
{
    public function __construct(
        private NormalizerInterface $normalizer,
        private Security $security,
    ) {}

    public function normalize(mixed $object, ?string $format = null, array $context = []): array
    {
        /** @var Post $object */
        $data = $this->normalizer->normalize($object, $format, $context);

        // Add admin-only fields
        if ($this->security->isGranted('ROLE_ADMIN')) {
            $data['internalNotes'] = $object->getInternalNotes();
            $data['moderationStatus'] = $object->getModerationStatus();
        }

        // Add owner-only fields
        if ($this->security->getUser() === $object->getAuthor()) {
            $data['analytics'] = [
                'views' => $object->getViewCount(),
                'engagement' => $object->getEngagementRate(),
            ];
        }

        return $data;
    }

    public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
    {
        return $data instanceof Post;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [Post::class => true];
    }
}

Context Builders

Dynamic Groups Based on User

<?php
// src/Serializer/UserContextBuilder.php

namespace App\Serializer;

use ApiPlatform\Serializer\SerializerContextBuilderInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;

final class UserContextBuilder implements SerializerContextBuilderInterface
{
    public function __construct(
        private SerializerContextBuilderInterface $decorated,
        private Security $security,
    ) {}

    public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
    {
        $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);

        // Add admin group for admin users
        if ($this->security->isGranted('ROLE_ADMIN')) {
            $context['groups'][] = 'admin:read';
        }

        // Add owner group when viewing own resources
        $resourceClass = $context['resource_class'] ?? null;
        if ($resourceClass && $this->isOwner($request, $resourceClass)) {
            $context['groups'][] = 'owner:read';
        }

        return $context;
    }

    private function isOwner(Request $request, string $resourceClass): bool
    {
        // Implementation depends on your resource structure
        return false;
    }
}

Register as decorator:

# config/services.yaml
services:
    App\Serializer\UserContextBuilder:
        decorates: 'api_platform.serializer.context_builder'

Max Depth

Prevent circular references:

use Symfony\Component\Serializer\Attribute\MaxDepth;

class User
{
    #[MaxDepth(1)]
    #[Groups(['user:read'])]
    private Collection $posts;
}

class Post
{
    #[MaxDepth(1)]
    #[Groups(['post:read'])]
    private User $author;
}

Enable in configuration:

#[ApiResource(
    normalizationContext: [
        'groups' => ['post:read'],
        'enable_max_depth' => true,
    ],
)]
class Post { /* ... */ }

Ignore Properties

use Symfony\Component\Serializer\Attribute\Ignore;

class User
{
    #[Ignore]
    private string $password;

    #[Ignore]
    private string $resetToken;
}

Best Practices

  1. Use groups consistently: entity:operation naming convention
  2. Separate read/write groups: Different fields for input/output
  3. Limit nested depth: Use MaxDepth to prevent deep nesting
  4. Computed fields in normalizers: Keep entities clean
  5. Context builders for dynamic groups: Role-based field access
  6. Document with OpenAPI: Groups affect schema generation