Author ManyToOne Relation to User

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

Check out the homepage: every Article has an author. But, open the Article entity. Oh: the author property is just a string!

... lines 1 - 15
class Article
{
... lines 18 - 47
/**
* @ORM\Column(type="string", length=255)
*/
private $author;
... lines 52 - 247
}

When we originally created this field, we hadn't learned how to handle database relationships yet.

But now that we are way more awesome than "past us", let's replace this author string property with a proper relation to the User entity. So every Article will be "authored" by a specific User.

Wait... why are we talking about database relationship in the security tutorial? Am I wandering off-topic again? Well, only a little. Setting up database relations is always good practice. But, I have a real, dubious, security-related goal: this setup will lead us to some really interesting access control problems - like denying access to edit an Article unless the logged in user is that Article's author.

Let's smash this relationship stuff so we can get to that goodness! First, remove the author property entirely. Find the getter and setter methods and remove those too. Now, find your terminal and run:

php bin/console make:migration

If our app were already deployed, we might need to be a little bit more careful so that we don't lose all this original author data. But, for us, no worries: that author data was garbage! Find the Migrations/ directory, open up the new migration file and yep! ALTER TABLE Article DROP author:

... lines 1 - 2
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180901184240 extends AbstractMigration
{
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 article DROP author');
}
public function down(Schema $schema) : void
{
// this down() 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 article ADD author VARCHAR(255) NOT NULL COLLATE utf8mb4_unicode_ci');
}
}

Adding the Relation

Now, lets re-add author as a relation:

php bin/console make:entity

Update the Article entity and add a new author property. This will be a "relation" to the User entity. For the type, it's another ManyToOne relation: each Article has one User and each User can have many articles. The author property will be required, so make it not nullable. We'll say "yes" to mapping the other side of the relationship and I'll say "no" to orphanRemoval, though, that's not important. Cool! Hit enter to finish:

... lines 1 - 15
class Article
{
... lines 18 - 68
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="articles")
* @ORM\JoinColumn(nullable=false)
*/
private $author;
... lines 74 - 237
public function getAuthor(): ?User
{
return $this->author;
}
public function setAuthor(?User $author): self
{
$this->author = $author;
return $this;
}
}

Now run:

php bin/console make:migration

Like always, let's go check out the new migration:

... lines 1 - 2
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180901184346 extends AbstractMigration
{
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 article ADD author_id INT NOT NULL');
$this->addSql('ALTER TABLE article ADD CONSTRAINT FK_23A0E66F675F31B FOREIGN KEY (author_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_23A0E66F675F31B ON article (author_id)');
}
public function down(Schema $schema) : void
{
// this down() 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 article DROP FOREIGN KEY FK_23A0E66F675F31B');
$this->addSql('DROP INDEX IDX_23A0E66F675F31B ON article');
$this->addSql('ALTER TABLE article DROP author_id');
}
}

Woh! I made a mistake! It is adding author_id but it is also dropping author. But that column should already be gone by now! My bad! After generating the first migration, I forgot to run it! This diff contains too many changes. Delete it. Then, execute the first migration:

php bin/console doctrine:migrations:migrate

Bye bye original author column. Now run:

php bin/console make:migration

Go check it out:

... lines 1 - 2
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180901184346 extends AbstractMigration
{
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 article ADD author_id INT NOT NULL');
$this->addSql('ALTER TABLE article ADD CONSTRAINT FK_23A0E66F675F31B FOREIGN KEY (author_id) REFERENCES user (id)');
$this->addSql('CREATE INDEX IDX_23A0E66F675F31B ON article (author_id)');
}
public function down(Schema $schema) : void
{
// this down() 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 article DROP FOREIGN KEY FK_23A0E66F675F31B');
$this->addSql('DROP INDEX IDX_23A0E66F675F31B ON article');
$this->addSql('ALTER TABLE article DROP author_id');
}
}

Much better: it adds the author_id column and foreign key constraint. Close that and, once again, run:

php bin/console doctrine:migrations:migrate

Failed Migration!

Woh! It explodes! Bad luck! This is one of those tricky migrations. We made the new column required... but that field will be empty for all the existing rows in the table. That's not a problem on its own... but it does cause a problem when the migration tries to add the foreign key! The fix depends on your situation. If our app were already deployed to production, we would need to follow a 3-step process. First, make the property nullable=true at first and generate that migration. Second, run a script or query that can somehow set the author_id for all the existing articles. And finally, change the property to nullable=false and generate one last migration.

But because our app has not been deployed yet... we can cheat. First, drop all of the tables in the database with:

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

Then, re-run all the migrations to make sure they're working:

php bin/console doctrine:migrations:migrate

Awesome! Because the article table is empty, no errors.

Adding Article Author Fixtures

Now that the database is ready, open ArticleFixtures. Ok: this simple setAuthor() call will not work anymore:

... lines 1 - 10
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface
{
... lines 13 - 24
private static $articleAuthors = [
'Mike Ferengi',
'Amy Oort',
];
... line 29
protected function loadData(ObjectManager $manager)
{
$this->createMany(10, 'main_articles', function($count) use ($manager) {
... lines 33 - 59
$article->setAuthor($this->faker->randomElement(self::$articleAuthors))
... lines 61 - 62
;
... lines 64 - 70
});
... lines 72 - 73
}
... lines 75 - 81
}

Nope, we need to relate this to one of the users from UserFixture. Remember we have two groups: these main_users and these admin_users:

... lines 1 - 9
class UserFixture extends BaseFixture
{
... lines 12 - 18
protected function loadData(ObjectManager $manager)
{
$this->createMany(10, 'main_users', function($i) use ($manager) {
... lines 22 - 40
});
$this->createMany(3, 'admin_users', function($i) {
... lines 44 - 54
});
... lines 56 - 57
}
}

Let's allow normal users to be the author of an Article. In other words, use $this->getRandomReference('main_users') to get a random User object from that group:

... lines 1 - 10
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface
{
... lines 13 - 24
protected function loadData(ObjectManager $manager)
{
$this->createMany(10, 'main_articles', function($count) use ($manager) {
... lines 28 - 54
$article->setAuthor($this->getRandomReference('main_users'))
... lines 56 - 57
;
... lines 59 - 65
});
... lines 67 - 68
}
... lines 70 - 77
}

At the top of the class, I can remove this old static property.

Try it! Move over and run:

php bin/console doctrine:fixtures:load

It works! But... only by chance. UserFixture was executed before ArticleFixtures... and that's important! It would not work the other way around. We just got lucky. To enforce this ordering, at the bottom of ArticleFixtures, in getDependencies(), add UserFixture::class:

... lines 1 - 10
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface
{
... lines 13 - 70
public function getDependencies()
{
return [
... line 74
UserFixture::class,
];
}
}

Now UserFixture will definitely run before ArticleFixtures.

If you try the fixtures again:

php bin/console doctrine:fixtures:load

Same result. But now, it's guaranteed!

Next - let's finish our refactoring and create a new "Article Edit" page!

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.0
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.4
        "symfony/console": "^4.0", // v4.1.4
        "symfony/flex": "^1.0", // v1.2.7
        "symfony/framework-bundle": "^4.0", // v4.1.4
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.4
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.4
        "symfony/web-server-bundle": "^4.0", // v4.1.4
        "symfony/yaml": "^4.0", // v4.1.4
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.4
        "symfony/dotenv": "^4.0", // v4.1.4
        "symfony/maker-bundle": "^1.0", // v1.7.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.4
    }
}