| version | |
|---|---|
| symfony | 7.4 |
| symfony/object-mapper | 7.4 |
| api-platform/symfony | 4.2 |
| php | 8.4 |
Describe the problem
So I've been reading about the new recommandation for API Platform which is to use and map DTO instead of writing API Platform ressources metadata directly through the Entity.
See also https://api-platform.com/docs/core/dto/
So I've been trying to implement this with no luck yet.
Illustrate the problem with my code
Here's my simple entity :
# src/Entity
namespace App\Entity;
#[ORM\Entity(repositoryClass: CompanyRepository::class)]
#[ORM\Table(name: 'company')]
class Company
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::BIGINT)]
#[Groups(['read:company'])]
private ?int $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['read:company'])]
private string $name = '';
...
}
Here's my input Resource :
# src/Domain/Company/ApiResource
namespace App\Domain\Company\ApiResource;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Entity\Company;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/company',
/** @todo security: 'is_granted("ROLE_ADMIN")', */
normalizationContext: ['groups' => ['read:company']],
output: CompanyCreateOutput::class,
name: 'create_company',
),
],
stateOptions: new Options(entityClass: Company::class),
)]
#[Map(target: Company::class)]
class CompanyCreateInput
{
public string $name = '';
}
Now here's my output :
# src/Domain/Company/ApiResource
namespace App\Domain\Company\ApiResource;
use App\Entity\Company;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Serializer\Attribute\Groups;
#[Map(source: Company::class)]
class CompanyCreateOutput
{
#[Groups(['read:company'])]
public int $id;
#[Groups(['read:company'])]
public string $name;
#[Groups(['read:company'])]
#[Map(source: 'created_at')]
public string $createdAt;
}
But well now when I call the route such as
POST {{url}}/company
{
"name": "This is a test"
}
I got the following response :
{
"@context": {
"@vocab": "https://api.project.local/api/docs.jsonld#",
"hydra": "http://www.w3.org/ns/hydra/core#",
"name": "CompanyCreateInput/name"
},
"@type": "CompanyCreateInput",
"@id": "/api/.well-known/genid/28ed6cfb3faf268d111c"
}
Which is to me absolutely insane response.
What I'm trying to get
I want that when I create a resource (with a POST) that it return the created resource such as :
{
"id": 12345,
"name": This is a test",
"created_at": "29/12/2025 00:00:00"
}
What I've been trying
I've been reading how it's been proceed to normalize the object the standard way using the ItemNormalizer::normalize which do include the output argument but it does not use it... It look like an error to me...
I've been trying to create a custom provider
CompanyCreateRepresentationProviderbut I didn't know how to use it and it does not looked like to be actually used ...
1 Answer 1
yeah so api platform maintainer soyuka actually admitted. the recommended approach now is Input DTO as resource, output for docs, and do your thing in processor.
your problem is that ObjectMapperProvider and ObjectMapperProcessor handle output differently. provider does it right
$class = $operation->getOutput()['class'] ?? $operation->getClass();
but processor just ignores output entirely
return $this->objectMapper->map($persisted, $operation->getClass());
so it maps your entity back to Input DTO instead of Output. fun stuff.
anyway, use map: false to skip that whole mess and return Output DTO yourself:
#[ApiResource(
operations: [
new Post(
uriTemplate: '/company',
output: CompanyCreateOutput::class,
processor: CompanyCreateProcessor::class,
map: false,
),
],
)]
class CompanyCreateInput
{
#[Assert\NotBlank]
public string $name = '';
}
final readonly class CompanyCreateProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): CompanyCreateOutput
{
$company = new Company();
$company->setName($data->name);
$this->entityManager->persist($company);
$this->entityManager->flush();
$output = new CompanyCreateOutput();
$output->id = $company->getId();
$output->name = $company->getName();
$output->createdAt = $company->getCreatedAt()?->format(\DateTimeInterface::ATOM);
return $output;
}
}
map is legit btw. when false, processor skips its logic.
see also issues: 1, 2