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!

  • 2020-03-09 weaverryan

    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!

  • 2020-03-05 horlyk

    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.

  • 2020-03-05 Victor Bocharsky

    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!

  • 2020-03-02 horlyk

    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.

  • 2020-02-28 horlyk

    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?

  • 2019-10-14 Diego Aguiar

    Hey Benjamin Quarta

    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!

  • 2019-10-13 Benjamin Quarta

    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

  • 2019-07-10 Sung Lee

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

  • 2019-07-09 Diego Aguiar

    Hey Sung Lee

    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!

  • 2019-07-09 Sung Lee

    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.