Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Relating Resources

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We have a cheese resource and a user resource. Let's link them together! Ok, the real problem we need to solve is this: each CheeseListing will be "owned" by a single user, which is something we need to set up in the database but also something we need to expose in our API: when I look at a CheeseListing resource, I need to know which user posted it!

Creating the Database Relationship

Let's set up the database first. Find your terminal and run:

php bin/console make:entity

Let's update the CheeseListing entity and add a new owner property. This will be a relation to the User entity... which will be a ManyToOne relationship: every CheeseListing has one User. Should this new property be nullable in the database? Say no: every CheeseListing must have an owner in our system.

Next, it asks a super important question: do we want to add a new property to User so that we can access and update cheese listings on it - like $user->getCheeseListings(). Doing this is optional, and there are two reasons why you might want it. First, if you think writing $user->getCheeseListings() in your code might be convenient, you'll want it! Second, when you fetch a User in our API, if you want to be able to see what cheese listings this user owns as a property in the JSON, you'll also want this. More on that soon.

Anyways, say yes, call the property cheeseListings and say no to orphanRemoval. If you're not familiar with that option... then you don't need it. And... bonus! A bit later in this tutorial, I'll show you why and when this option is useful.

Hit enter to finish! As usual, this did a few things: it added an $owner property to CheeseListing along with getOwner() and setOwner() methods. Over on User, it added a $cheeseListings property with a getCheeseListings() method... but not a setCheeseListings() method. Instead, make:entity generated addCheeseListing() and removeCheeseListing() methods. Those will come in handy later.

... lines 1 - 37
class CheeseListing
{
... lines 40 - 84
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
*/
private $owner;
... lines 90 - 182
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
}

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
* @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner")
*/
private $cheeseListings;
... line 63
public function __construct()
{
$this->cheeseListings = new ArrayCollection();
}
... lines 68 - 156
public function getCheeseListings(): Collection
{
return $this->cheeseListings;
}
public function addCheeseListing(CheeseListing $cheeseListing): self
{
if (!$this->cheeseListings->contains($cheeseListing)) {
$this->cheeseListings[] = $cheeseListing;
$cheeseListing->setOwner($this);
}
return $this;
}
public function removeCheeseListing(CheeseListing $cheeseListing): self
{
if ($this->cheeseListings->contains($cheeseListing)) {
$this->cheeseListings->removeElement($cheeseListing);
// set the owning side to null (unless already changed)
if ($cheeseListing->getOwner() === $this) {
$cheeseListing->setOwner(null);
}
}
return $this;
}
}

Let's create the migration:

php bin/console make:migration

And open that up... just to make sure it doesn't contain anything extra.

... lines 1 - 12
final class Version20190509190403 extends AbstractMigration
{
... lines 15 - 19
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE cheese_listing ADD owner_id INT NOT NULL');
$this->addSql('ALTER TABLE cheese_listing ADD CONSTRAINT FK_356577D47E3C61F9 FOREIGN KEY (owner_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_356577D47E3C61F9 ON cheese_listing (owner_id)');
}
... lines 29 - 38
}

Looks good - altering the table and setting up the foreign key. Execute that:

php bin/console doctrine:migrations:migrate

Oh no! It exploded!

Cannot add or update a child row, a foreign key constraint fails

... on the owner_id column of cheese_listing. Above the owner property, we set nullable=false, which means that the owner_id column in the table cannot be null. But... because our cheese_listing table already has some rows in it, when we try to add that new column... it doesn't know what value to use for the existing rows and it explodes.

It's a classic migration failure. If our site were already on production, we would need to make this migration fancier by adding the new column first as nullable, set the values, then change it to not nullable. But because we're not there yet... we can just drop all our data and try again. Run:

php bin/console doctrine:schema:drop --help

... because this has an option I can't remember. Ah, here it is: --full-database will make sure we drop every table, including migration_versions. Run:

php bin/console doctrine:schema:drop --full-database --force

Now we can run every migration to create our schema from scratch:

php bin/console doctrine:migrations:migrate

Nice!

Exposing the Relation Property

Back to work! In CheeseListing, we have a new property and a new getter and setter. But because we're using normalization and denormalization groups, this new stuff is not exposed in our API.

To begin with, here's the goal: when we create a CheeseListing, an API client should be able to specify who the owner is. And when we read a CheeseListing, we should be able to see who owns it. That might feel a bit weird at first: are we really going to allow an API client to create a CheeseListing and freely choose who its owner is? For now, yes: setting the owner on a cheese listing is no different than setting any other field. Later, once we have a real security system, we'll start locking things down so that I can't create a CheeseListing and say that someone else owns it.

Anyways, to make owner part of our API, copy the @Groups() off of $price... and add those above $owner.

... lines 1 - 37
class CheeseListing
{
... lines 40 - 84
/**
... lines 86 - 87
* @Groups({"cheese_listing:read", "cheese_listing:write"})
*/
private $owner;
... lines 91 - 194
}

Let's try it! Move over and refresh the docs. But before we look at CheeseListing, let's create a User so we have some data to play with. I'll give this an email, any password, a username and... Execute. Great - 201 success. Tweak the data and create one more user.

Now, the moment of truth: click to create a new CheeseListing. Interesting... it says that owner is a "string"... which might be surprising... aren't we going to set this to the integer id? Let's find out. Try to sell a block of unknown cheese for $20, and add a description.

For owner, what do we put here? Let's see... the two users we just created had ids 2 and 1. Okay! Set owner to 1 and Execute!

Woh! It fails with a 400 status code!

Expected IRI or nested document for attribute owner, integer given.

It turns out that setting owner to the id is not correct! Next, let's fix this, talk more about IRIs and add a new cheeseListings property to our User API resource.

Leave a comment!

41
Login or Register to join the conversation

Hey The team,

For someone who want to use SF5 with API platform 2.6 and PHP 8, I succeed to write this and it is working perfectly fine :


#[ApiResource(
collectionOperations: ['get', 'post'],
itemOperations: ['get', 'put'],
shortName: 'Cheeses',
attributes: [
'pagination_items_per_page' => 2,
'formats' => [
'jsonld',
'json',
'html',
'jsonhal',
'csv' => ['text/csv']
]
],
denormalizationContext: ['groups' => 'writeCheese'],
normalizationContext: [ 'groups' => 'readCheese'],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'ipartial', 'description' => 'ipartial'])]
#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
class CheeseListing
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', length: 255)]
#[ApiProperty(description: 'Le nom de ce fromage.')]
#[Groups(['writeCheese', 'readCheese'])]
#[Assert\NotBlank()]
#[Assert\Length(['min' => 2, 'max' => 50, 'maxMessage' => 'Describe your cheese in 50 chars or less'])]
private string $title;

#[ORM\Column(type: 'text')]
#[ApiProperty(description: 'La description de ce fromage.')]
#[Groups(['readCheese'])]
#[Assert\NotBlank()]
private string $description;

#[ORM\Column(type: 'integer')]
#[ApiProperty(description: 'Le prix de ce délicieux fromage en centimes.')]
#[Groups(['writeCheese', 'readCheese'])]
#[Assert\NotBlank()]
#[Assert\GreaterThan(['value' => 0])]
private int $price;


}



#[ApiResource(
denormalizationContext: ['groups' => 'writeUser'],
normalizationContext: ['groups' => 'readUser'],
)]
#[UniqueEntity(fields: 'username')]
#[UniqueEntity(fields: 'email')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;

#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Groups(['writeUser', 'readUser'])]
#[Assert\NotBlank()]
#[Assert\Email()]
private string $email;

#[ORM\Column(type: 'json')]
private array $roles = [];

#[ORM\Column(type: 'string')]
#[Groups('writeUser')]
private string $password;

#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Groups(['readUser', 'writeUser'])]
#[Assert\NotBlank()]
private string $username;

#[ORM\OneToMany(mappedBy: 'owner', targetEntity: CheeseListing::class)]
#[Groups(['readUser'])]
private $cheeseListings;
1 Reply

Hey Stéphane!

Thank you for sharing this new alternative way with others! :)

Cheers!

Reply
David-G Avatar
David-G Avatar David-G | posted 7 hours ago | edited

Hi everybody
Note i learn Api platform with the version 3.0 and Symfony 6.1

I created the User Entity with the maker as explained in the video but the relation is not an IRI but a User Class in my CheeseListing entity.

User.php

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:write']]
)]
#[UniqueEntity(
    fields: ['username'],
    message: 'ce username existe déjà')]
#[UniqueEntity(
    fields: ['email'],
    message: 'email déjà utilisé')]

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]    
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Groups(['user:read', 'user:write'])]
    private ?string $email = null;

    #[ORM\Column]
    private array $roles = [];

    /**
     * @var string The hashed password
     */
    #[ORM\Column]    
    #[Groups(['user:read', 'user:write'])]
    private ?string $password = null;

    #[ORM\Column(length: 255, unique: true)]
    #[Groups(['user:read', 'user:write'])]
    private ?string $username = null;

    #[ORM\OneToMany(mappedBy: 'owner', targetEntity: CheeseListing::class)]
    private Collection $cheeseListings;

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

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

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

    public function setEmail(string $email): self
    {
        $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): self
    {
        $this->roles = $roles;

        return $this;
    }

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

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

        return $this;
    }

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

    public function getUsername(): ?string
    {
        return $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }

    /**
     * @return Collection<int, CheeseListing>
     */
    public function getCheeseListings(): Collection
    {
        return $this->cheeseListings;
    }

    public function addCheeseListing(CheeseListing $cheeseListing): self
    {
        if (!$this->cheeseListings->contains($cheeseListing)) {
            $this->cheeseListings->add($cheeseListing);
            $cheeseListing->setOwner($this);
        }

        return $this;
    }

    public function removeCheeseListing(CheeseListing $cheeseListing): self
    {
        if ($this->cheeseListings->removeElement($cheeseListing)) {
            // set the owning side to null (unless already changed)
            if ($cheeseListing->getOwner() === $this) {
                $cheeseListing->setOwner(null);
            }
        }

        return $this;
    }
}

Cheese_listing.php

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use App\Repository\CheeseListingRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Query\Expr\GroupBy;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
#[ApiResource(
    routePrefix: '/',
    operations: [
        new Get(
            uriTemplate: '/cheese/{id}.{_format}'
        ),
        new GetCollection(
            uriTemplate: '/cheeses.{_format}'
        ),
        new Post(
            uriTemplate: '/cheese'
        ),
        new Delete(
            uriTemplate: '/cheese/{id}.{_format}'
        )
        ],
    normalizationContext: ['groups' => 'user:read'],
    denormalizationContext: ['groups' => 'user:write'],
    formats: ['json', 'jsonld', 'html', 'jsonhal', 'csv' => ['text/csv']]   
)]

class CheeseListing
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(
        min: 3,
        max: 50
    )]
    #[Groups(['user:read', 'user:write'])]
    private ?string $title = null;
    
    #[ORM\Column(type: Types::TEXT)]
    #[Assert\NotBlank]
    #[Assert\Length(
        min: 5,
        max: 255,
        minMessage: 'Votre description doit faire plus de 5 caractères',
        maxMessage: 'Votre description ne doit pas faire plus de 255 caractères'
    )]
    #[Groups(['user:read', 'user:write'])]
    private ?string $description = null;
    
    #[ORM\Column]
    #[Assert\NotBlank]
    #[Groups(['user:read', 'user:write'])]
    private ?int $price = null;

    #[ORM\Column]
    #[Groups(['user:read'])]
    private ?DateTimeImmutable $createdAt = null;

    #[ORM\Column]
    #[Groups(['user:read', 'user:write'])]
    private ?bool $isPublished = null;

    #[ORM\ManyToOne(inversedBy: 'cheeseListings')]
    #[ORM\JoinColumn(nullable: false)]    
    #[Groups(['user:read', 'user:write'])]
    private ?User $owner = null;

    public function __construct()
    {
        // $this->createdAt = new DateTimeImmutable;
    }
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): self
    {
        $this->title = $title;

        return $this;
    }

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

    public function setDescription(string $description): self
    {
        $this->description = $description;

        return $this;
    }

    public function getPrice(): ?int
    {
        return $this->price;
    }

    public function setPrice(int $price): self
    {
        $this->price = $price;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeImmutable $createdAt): self
    {
        $this->createdAt = $createdAt;

        return $this;
    }

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

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

        return $this;
    }

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

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

        return $this;
    }
}

and the json example is :

[
  {
    "id": 0,
    "title": "string",
    "description": "string",
    "price": 0,
    "createdAt": "2022-10-06T16:19:58.323Z",
    "isPublished": true,
    "owner": {
      "email": "string",
      "password": "string",
      "username": "string"
    }
  }
]

No IRI, is that normal ? Did i make some mistake ?
I found nothing about that in the documentation but i'm not expert and my english is not so good.

Thanks :)

Reply
David-G Avatar
David-G Avatar David-G | David-G | posted 2 hours ago | edited

I reply on my question,
I found my mistake.

It's about groups in normalizationContext and denormalizationContext.
i used the same name of groups for my entity User and Cheese_listing, that's why, that create confusion.

To fix that i just rename the name of groups in User (adding 's', this is probably not a great idea, but that's ok for the exercise)

Cheese_listing.php

...
#[ApiResource(
    normalizationContext: ['groups' => ['user:read']],
    denormalizationContext: ['groups' => ['user:write']]
)]
...

User.php

...
#[ApiResource(
    normalizationContext: ['groups' => ['users:read']],
    denormalizationContext: ['groups' => ['users:write']]
)]
...
1 Reply

Hey David, might be better to just use user:read/write and cheese_lising_read/write but that's up to you.

Cheers!

1 Reply
Wojtek D. Avatar
Wojtek D. Avatar Wojtek D. | posted 3 months ago

Hi, i have an issue, and im kinda lost. Ive done 1:1 like you said and im getting invalid IRI error

Owner part in db:
/**
* @ORM\ManyToOne(targetEntity=Users::class, inversedBy="servers")
* @ORM\JoinColumn(nullable=false)
*/
#[Groups("user:write")]
private $owner;

denormalizationContext: ["groups" => ["user:write"]],
Any idea what could be wrong?

Reply
Wojtek D. Avatar

Well to be honest i sovled it, dunno how but i just did not manyToOne but OneToMany and targeted other table

Reply

Hey Vortex,

I'm happy to hear you were able to solve this yourself, well done! Well, it sounds like a cache issue to me :) Anyway, I'm glad it works for you now

Cheers!

Reply
ties8 Avatar

Hello, i have an issue: If i have a Entity, lets say Cat, and i have pictures of my cat which are connected in a ManyToMany relation (one image can include multiple cats), if i save the cat as
{
cat: 'James',
images: [
'iri1', 'iri2', 'iri3'
]
}
and then reorder the images and save the entity with
images: [
'iri1', 'iri3', 'iri2'
]
then this is not saved, the order of the related images is not saved as i specified it in my api call. Is there any smart way i can order my pictures whithout creating a intermediate Entity who contains an index?

Reply

Hey Alex

I think the only option would be to delete and recreate the image records in the new order. Otherwise, you'll need, as you said, an intermediate entity to store the order of each image

Cheers!

Reply
Raul M. Avatar

Hi again Ryan!

I have a new problem with many to many relations, but in this case with self-referencing. I have the entity employee with a many to many self-referencing with the property manager with the next configuration:


/**
* @ORM\ManyToMany(targetEntity=Empleado::class, mappedBy="subordinados")
* @Groups({
* "datos_personales:read",
* })
*
* @MaxDepth(1)
*/
private $responsables;

/**
* @ORM\ManyToMany(targetEntity=Empleado::class, inversedBy="responsables")
* @ORM\JoinTable(
* name="responsables",
* joinColumns={
* @ORM\JoinColumn(name="responsable_id",referencedColumnName="id")
* },
* inverseJoinColumns={
* @ORM\JoinColumn(name="persona_id",referencedColumnName="id")
* }
* )
*
* @Groups({
* "datos_personales:read",
* })
*
* @MaxDepth(1)
*/
private $subordinados;

Sorry but part of this code is in spanish.....

when I execute the query I didn't get any reply and the loading is spinning without end. Could you help me with this issue?

Thanks in advance!

Cheers,

Raúl.

Reply

Hey Raul,

Hm, it's like you wait for 30 seconds and then the script dies? If the request is spinning without end - it sounds like you may have a recursion. I'd recommend you to install and enable Xdebug for your PHP - it will detect any recursions and throw an exception, so it will be more obvious where the problem is. Manually, it's difficult to find what's wrong, but first of all you need to double-check any recursive calls if you have.

Also, I'd recommend you to check the logs, they may contain some tips about what went wrong.

Btw, if you're not sure in your entity mapping config -you may want to execute "bin/console doctrine:schema:validate" command to validate the mapping and the schema in your project - it should give the output with both mapping and schema green, otherwise you have problems in relations that you have to fix first.

I hope this helps! Sorry can't help more

Cheers!

Reply
Raul M. Avatar

Hi Victor,

Thank you for your reply!

I made a mistake with the confirguration of the relation, but now it is solved:


/**
* @ORM\ManyToMany(targetEntity=DatosPersonales::class, mappedBy="subordinados")
* @Groups({
* "datos_personales:read"
* })
* @MaxDepth(1)
*/
private $responsables;

/**
* @ORM\ManyToMany(targetEntity=DatosPersonales::class, inversedBy="responsables")
* @ORM\JoinTable(name="responsables",
* joinColumns={@ORM\JoinColumn(name="responsable_id", referencedColumnName="id")},
* inverseJoinColumns={@ORM\JoinColumn(name="subordinado_id", referencedColumnName="id")}
* )
* @Groups({
* "datos_personales:read"
* })
* @MaxDepth(1)
*/
private $subordinados;

Now I have a doubt about the serialization. In the reply of this end point I have a full object of the employee entity in the responsables and subordinados array, but I only need name and surname. How could I solve it?

I has been reading about custom normalizers but I don't know how I can implement it in my project.

Thank you for your help!

Cheers,

Raúl

Reply

Hey Raul M.!

Yea, this gets tricky :). The key is serialization groups. We talk about it here: https://symfonycasts.com/sc...

So the key should be to add the datos_personales:read to your employee entity only above the fields you want (name and surname). I'm not sure, however, how MaxDepth works with this, I don't really use that (I don't think you should actually need MaxDepth, but you can try with and without).

Cheers!

Reply
Raul M. Avatar
Raul M. Avatar Raul M. | weaverryan | posted 7 months ago | edited

Hi @weaverryan !

Thank you for your reply.

As this is a self referencing entity all the fields has the serialization group datos_personales:read, including $responsables and $subordinados.

This two fields are referencing also the self entity employee, but I need to normalize this fields only with name and surname, and not with all the fields of the entity.

I've seen the video but is about relation between two entities and this is not my case.

Thank you for your help!

Cheers,

Raúl.

1 Reply

Hey Raul M.!

Hmm. In this case, it's trick and I'm actually not sure what the best way is to handle thing. My guess is that a custom normalizer might be able to do this... where you use some context key to "keep track" of which level you're on, but I'm guessing a bit. I did have a gist that originally did something kind of similar - https://gist.github.com/wea... - and later a user added an alternate solution (I'm not sure this gist does exactly what you want, but it shows using decoration to "extend" the normalizer https://gist.github.com/vol... - here is his original comment: https://symfonycasts.com/sc...

Anyways, I know those links aren't terribly specific, but let me know if they help :).

Cheers!

Reply
Raul M. Avatar
Raul M. Avatar Raul M. | weaverryan | posted 7 months ago | edited

Hiweaverryan!

Thank you for your help and I'll take a look at the links you sent me.

Cheers!

Raúl.

Reply
Raul M. Avatar

Hi Ryan,

How we deal with many to many relations and Api Platform? Do you information about this case?

Thank you!

Raúl.

Reply

Hey Raul M.!

Hmm. I don't think we cover that directly. However, it should be basically the same as a OneToMany relationship. For example, imagine you have an Article ApiResource and a Tag ApiResource. You would set the tags on an Article via something like this:


PUT /api/articles/1

{
"tags": [
"/api/tags/1",
"/api/tags/2"
]
}
</coe>



If you set things up correctly (just like with a OneToMany relationship), I believe that you should even be able to create tags in this request. This is probably the area of the tutorial to look at - https://symfonycasts.com/screencast/api-platform/collections-write. From Api Platform's perspective, a ManyToMany and OneToMany are basically the same because in both cases you have an entity (e.g. Article or User) that has an array of some other entity (e.g. Tags or Articles).

Cheers!

Reply
Raul M. Avatar

I´ve got a similary question but a bit more specific,
I have 4 tables: User, Manger(has user_id column), Teams and User-Teams as many to many relationship, and i want to filtter on Manager operation GET by team_id, how i supposed to call this on the fillter annotation?
Thanks for all
Mario

Reply

Hey Raul M.!

Apologies for the slow reply! There are two possibilities... but I'm not sure if the first will work. We'll find out!

1) Follow the advice here: https://symfonycasts.com/sc...

Basically, the final API request would look something like this: GET /api/managers?user.teams=3 where 3 is the team id. I KIND of think that this won't work, but it's worth a try. You might also need to try GET /api/managers?user.teams[]=3. But again, I'm not sure this will do it.

2) Your other option is to create a custom filter. You can find info about that here: https://symfonycasts.com/sc...

Good luck!

Reply
Hazhir A. Avatar
Hazhir A. Avatar Hazhir A. | posted 2 years ago

I have a question how can we have a multi level child parent entity in one table in api platform!!! It does not show the relation when i create it in one table!! I have implemented the one to many!! This is just when using api-platform swagger

here is my code and database schema:

subject is the table name also parent_id could contain an id of subject entity, "N" level parent/childs



namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\SubjectRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
* @ApiResource(
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "delete" },
* shortName="Subjects",
* normalizationContext={"groups"={"subjects:read"}, "swagger_definition_name"="Read"},
* denormalizationContext={"groups"={"subjects:write"}, "swagger_definition_name"="Write"},
* attributes={"pagination_items_per_page" = 30}
* )
* @ORM\Entity(repositoryClass=SubjectRepository::class)
* @ORM\Table(name="`subject`")
*/
class Subject
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @ORM\Column(type="string", length=255)
* @Groups({"subjects:read", "subjects:write"})
*/
private $title;

/**
* @ORM\ManyToOne(targetEntity=Subject::class, inversedBy="subjects")
* @Groups({"subjects:read", "subjects:write"})
*/
private $parent;

/**
* @ORM\OneToMany(targetEntity=Subject::class, mappedBy="parent")
*/
private $subjects;

/**
* @ORM\OneToMany(targetEntity=Ticket::class, mappedBy="subject", orphanRemoval=true)
*/
private $tickets;

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

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

public function getTitle(): ?string
{
return $this->title;
}

public function setTitle(string $title): self
{
$this->title = $title;

return $this;
}

public function getParent(): ?self
{
return $this->parent;
}

public function setParent(?self $parent): self
{
$this->parent = $parent;

return $this;
}

/**
* @return Collection|self[]
*/
public function getSubjects(): Collection
{
return $this->subjects;
}

public function addSubject(self $subject): self
{
if (!$this->subjects->contains($subject)) {
$this->subjects[] = $subject;
$subject->setParent($this);
}

return $this;
}

public function removeSubject(self $subject): self
{
if ($this->subjects->contains($subject)) {
$this->subjects->removeElement($subject);
// set the owning side to null (unless already changed)
if ($subject->getParent() === $this) {
$subject->setParent(null);
}
}

return $this;
}

/**
* @return Collection|Ticket[]
*/
public function getTickets(): Collection
{
return $this->tickets;
}

public function addTicket(Ticket $ticket): self
{
if (!$this->tickets->contains($ticket)) {
$this->tickets[] = $ticket;
$ticket->setSubject($this);
}

return $this;
}

public function removeTicket(Ticket $ticket): self
{
if ($this->tickets->contains($ticket)) {
$this->tickets->removeElement($ticket);
// set the owning side to null (unless already changed)
if ($ticket->getSubject() === $this) {
$ticket->setSubject(null);
}
}
return $this;
}
}

each parent_id is the same id of the subject entity , which means each subject can be either a parent when the parent_id is null or be the child of another parent entity, the above code should display parent_id in the swagger but there is only title

Reply

Hey @hazhir!

Hmm. Other than this being a self-referencing relation, it seems pretty straightforward and I don't see any issues with your code.

I have a question: do you actually "receive"/see the "parent" field when you *use* the API endpoint? You mentioned that Swagger doesn't display parent_id. I'm wondering if this is just a documentation problem (it does not show in swagger but DOES exist when using the API endpoint) or if the field does not show anywhere.

Also, you mentioned that you're expecting "parent_id". Why that field? I would expect that "parent" would actually be an embedded object, with the normal @type and @id but also title.

Cheers!

Reply
Hazhir A. Avatar

no the "parent field" does not show up in post method which should be displayed so i can related object to it, but when using get method it will return the "parent" field this is really wierd situation and it's been month i am looking for a solution i also checked the whole stackoverflow , is this some kind of bug? or am i doing something wrong cause i assume people should have done something simillar to what i have done here.

Reply

Hey @hazhir!

Ah, so the problem is that you can't SET the parent field on POST, correct? Can you take a screenshot of what the Swagger UI looks like?

So I *can* think of one possible issue. Normally, I would expect that you could POST to create a new subject and include a "parent" field set to the IRI of some other subject (e.g. /api/subjects/2). However, because this is a self-referencing relationship, API Platform will see that the "title" field is in the subjects:write group, and it may think that you intend for the "parent" field to be an embedded object - e.g. {"title": "the new subject", parent: { title: "an embedded subject, which will actually create a new subject" }}.

To see if I'm correct, here are a few things to try / tell me:

1) Please take a screenshot of the swagger UI - specifically the POST operations for Subject where you can fill in the fields to "try it".

2) Try sending a POST to /api/subjects with the data {"title": "the new subject", parent: { title: "an embedded subject, which will actually create a new subject" }}. Does that work?

3) Try sending a POST to /api/subjects with the data {"title": "the new subject", parent: { "@id": "/api/subjects/1" }} where /api/subjects/1 is the IRI to a real subject. Does it work?

Let me know :).

Cheers!

Reply
Sebastian K. Avatar
Sebastian K. Avatar Sebastian K. | posted 2 years ago

I have a problem with a OneToOne relations. In my case: A User (API Resource) and UserSettings (just an entity).

I added my user:read and user:write groups to private $settings and also my 2 properties in UserSettings. I want to have this:


{
"@id": "/users/123",
...
"settings": {
"darkMode": true,
"language": 'de'
}
}

I can create a new user with settings, but when I want to update a user and change a key in my settings ("language" to "fr"), Symfony (Doctrine) tries to create a new entity (But runs into an Exception due to Unique-Key on user_id from OneToOne) instead of updating the existing one.

I know why, because I have no @id in my settings (https://api-platform.com/do..., but I think I don't need one, because it's a OneToOne. Either there is already a settings entity, then update it, or there is no entity, then create a new one.

This is my current solution. I added a check if the settings already exists, then I update the keys (very tedious and error prone)


public function setSettings(UserSettings $settings): self
{
if ($this->settings) {
$this->settings->setDarkMode($settings->getDarkMode());
$this->settings->setLanguage($settings->getLanguage());
} else {
$this->settings = $settings;
if ($settings->getUser() !== $this) {
$settings->setUser($this);
}
}

return $this;
}

I am looking for a better one

Reply

Hey Sebastian K.!

Sorry for the slow reply - you get the prize for asking a hard enough question that it had to wait for me ;).

First, you did a great job diagnosing the problem. If I've jumped correctly into the code, the place that looks for the @id is this: https://github.com/api-plat...

The key during the deserialization process is that the OBJECT_TO_POPULATE context needs to be set. Unfortunately, I'm not aware (which doesn't mean it's not possible - I just don't know) of a way to change the context for just *one* part of the deserialization process - other than creating a custom denormalizer that handles UserSettings and passes a different context to the "inner" deserializer.

So... I think the option is to either try messing with the custom deserializer or your solution (which is a bit messy, but quite clever).

Cheers!

Reply
David B. Avatar
David B. Avatar David B. | posted 2 years ago

Running into a strange issue with relationships and swagger.

Code: https://pastebin.com/M8aZ9g1v

I currently have MenuAudience - OneTOMany - MenuAudienceCategory. In Swagger UI I'm able to post many MenuAudience without issue. When I try to post MenuAudienceCategory using the following code it works fine.


{
"name": "test",
"description": "test",
"orderNumber": 1,
"published": true,
"menuAudience": "/index.php/api/menu_audiences/1"
}

However, if I try to post another MenuAudienceCategory using the code below it throws this error "hydra:description": "Could not denormalize object of type \"App\\Entity\\menuAudience\", no supporting normalizer found."; This is strange because I was able to post a 'MenuAudienceCategory' without any issues seconds before. If i clear my cache and try to post again it works fine. It appears that I can only create one MenuAudienceCategory at a time, then I must clear my cache to post another.


// Works after I clear cache.
{
"name": "test 2",
"description": "test 2",
"orderNumber": 2,
"published": true,
"menuAudience": "/index.php/api/menu_audiences/1"
}

Any ideas as to what might be causing this?

Reply

Hey David B. !

Ah, this is *super* weird! Quick question, when you see the error:

> Could not denormalize object of type \"App\\Entity\\menuAudience\", no supporting normalizer found."

Does it literally say menuAudience with a lowercase M on Menu? If so, that looks weird to me. And, oh! Yes, indeed - I see the cause now, inside MenuAudienceCategory, you have:


/**
* @ORM\ManyToOne(targetEntity=menuAudience::class, inversedBy="mealAudienceCategories")
* @ORM\JoinColumn(nullable=false)
*/
private $menuAudience;

See that lowercase "m" on menuAudience::class? Try changing that to a big M. I'm actually surprised that's working at all.. but maybe some parts of the system are being extra friendly (at least for the first request).

I could also be *totally* off - but that looks weird enough that I wanted to check it first.

Cheers!

Reply
David B. Avatar

Thanks for the reply! I think I managed to get it. I believe it was an issue with my docker setup and cache permissions. I tested it on a vagrant setup and it worked fine. I changed some permissions around in docker and regenerated the entities using make:entity and it seems to be working now.

Reply

Hey David B.!

Yea, that makes sense on a high level - it's *such* weird behavior, that I was also thinking that it must be some super-odd caching or other system-related problem. Glad you got it sorted!

Cheers!

Reply

Is there a way to order a collection by field in related entity?

Suppose, I have an entity "Category". It has a relation OneToMany to "Tag" entity. Tag entity has a relation to "TagTranslation" entity(knp translatable is used). So, when I'm getting Category with it's tags I want to order all the tags(only this collection, not Categories) by tags.translations.title property. Any ideas?

Reply

I've come up with a solution, which is not perfect, but works


public function getTags(): Collection
{
$iterator = $this->tags->getIterator();
$iterator->uasort(function (Tag $a, Tag $b) {
/** getTitle() is a shortcut to $this->translate() method which reads data from TagTranslation entity **/
return strnatcasecmp($a->getTitle(), $b->getTitle());
});

$this->tags->clear();

foreach ($iterator as $item) {
$this->tags->add($item);
}


return $this->tags;
}

As I use serializer to return an API response, using API Platform, there was an issue returning

 return new ArrayCollection(iterator_to_array($iterator));

It returns responses in different formats: array of objects or object with objects.
Array of objects is a correct one from PersistentCollection

 [0:{...}, 1:{...}]


Object with objects for ArrayCollection

 {0:{...}, 1:{...}};

The only difference is that a default class is a PersistentCollection and I was returning an ArrayCollection. So I've come up with a quick fix with clearing the Persistent collection and readding elements in a correct order.

Anyway it works, but it's interesting if there are some better solutions to this.

Reply

Hey Horlyk,

You can take a look at @ORM\OrderBy() annotation for ArrayCollection fields, see an example here: https://symfonycasts.com/sc... - I think it might be useful in this case.

Also, Doctrine Criteria will be a more complex but good and most flexible solution, take a look at: https://symfonycasts.com/sc...

You can create a new method like getTagsOrdered() for example apply the doctrine criteria to order array collection of $this->tags field.

I hope this helps!

Cheers!

Reply

Hi Victor,

Thanks for the response. But @ORM\OrderBy() and then Criteria - were the first steps which I've tried. And they both don't allow to make any joins so I wasn't able to access the needed field for ordering. @ORM\OrderBy({"translations.title" = "ASC"}) returned an error that it is unknown field. And The criteria does not support joins to add orderBy.

Reply

Hey horlyk!

I believe that's correct - I believe both OrderBy & Criteria are limited: they don't work across a relationship. Normally, I would warn that the solution you chose above could be inefficient: because you're querying for all the tags and then ordering them in PHP. But... because you are ultimately going to be displaying all the related tags in JSON anyways, then you will always be querying and hydrating all the objects anyways :). So of course, since you are rendering all the tags, you need to make sure that something doesn't have 100 tags (that would make the endpoint very slow), but otherwise, I don't see a problem with this.

Cheers!

1 Reply
bquarta Avatar
bquarta Avatar bquarta | posted 2 years ago

Hello there,

i've got a question about "modularity". All this stuff in the tutorial works well for me, but given that I want to add components that are reusable and modular, I probably might want not to have those relations directly in the user entity.

Think of the following: We have a base User Class and the job of the User class is to authenticate to the API. So let's say I want to build some components around the user class for different use-cases: A cheese-listing component, a blog-component and so on. Given, that eventually I want to drop the cheese-listing-component, i will have to clean up the User-Entity too.

Is there a more modular way, such as having an CheeseOwner-Class that is "extending" User and that is holding only the cheese-listing-relations?
And the other way round: If i inherit by different modules i might have a CheeseOwner, that "knows" all those User-properties, but not about the properties of the BlogAuthor-Entity. But what if eventually I will need an endpoint that holds all the information of the components that are currently installed?

best regards
Ben

Reply

Hey bquarta

Let's see, if you want your User class to be "agnostic" of any other module within your system, then it can't hold any reference to the other entities or functionality. What you can do is to invert the dependencies, if your users can post comments, then the Comment entity would hold the reference to its user, and the same applies to the CheeseLisiting entity. If you want to fetch all comments for a given user, then you have to do it from the Blog (or CheeseListing) repository $blogRepo->findAllForUser($user);
By doing so, you can remove entirely your Blog component and the User component won't even notice it

I hope this makes any sense to you :) Cheers!

1 Reply
Sung L. Avatar
Sung L. Avatar Sung L. | posted 3 years ago

Hi,

With the awesome tutorials, I am able to handle One-to-Many relations pretty easily and Many-to-Many relations with the bridge table. However, there are extra information in bridge table and cannot pull the data from API. Here is example schema:


Users
---
id
name
age

Teams
---
id
name

User_Teams
---
user_id
team_id
is_leader

I am able to pull data from User API with team info (id and name), and Team data with Users (id, name, and age) in it. But is_leader from the bridge table cannot be pulled from anywhere. How can I add is_leader in the Users/Teams api? Thanks for your help.

Reply

Hey Sung L.

Adding extra fields to a "bridge" table when using Doctrine's ManyToMany relationship is not possible, you have to do a workaround. Read my answer here: https://symfonycasts.com/sc...
and you can watch this chapter where Ryan explains it even more: https://symfonycasts.com/sc...

Cheers!

Reply
Sung L. Avatar

Exactly what I was looking for! Thanks, Diego for your kind reply.

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}