The Answer Entity
Oh hey there friends! Welcome back to part 2 of our Doctrine in Symfony series... you wonderful database nerds you.
Last time we mastered the basics, but good stuff! Creating an entity, migrations, fixtures, saving, querying and making the perfect omelette... I think. This time, we're going to do some mega study on Doctrine relations.
Project Setup
So let's get our project rocking. To avoid foreign key constraints in your brain while watching the tutorial, I recommend downloading the course code from this page and coding along with me. After unzipping the file, you'll find a start/
directory with all the fancy files that you see here. Check out the README.md
file for all the fun details on how to get this project running.
The last step will be to find a terminal, move into the project and run:
symfony serve -d
I'm using the Symfony binary to start a local web server. Let's go see our site. Spin over to your browser and head to https://127.0.0.1:8000.
Oh, hey there Cauldron Overflow! This is a site where the budding industry of witches and wizards can come to ask questions... after - sometimes - prematurely shipping their spells to production... and turning their clients into small adorable frogs. It could be worse.
The questions on the homepage are coming from the database... we rock! We built a Question
entity in the first tutorial. But if you click into a question... yea. These answers? These are totally hard-coded. Time to change that.
Making the Answer Entity
I want you to, for now, forget about any potential relationship between questions and answers. It's really simple: our site has answers! And so, if we want to store those answers in the database, we need an Answer
entity.
At your terminal, let's generate one. Run:
symfony console make:entity
Now, as a reminder, symfony console
is just a fancy way of saying php bin/console
. I'm using the Docker & Symfony web server integration. That's where the Symfony web server reads your docker-compose.yaml
file and exposes environment variables to the services inside of it. We talked about that in the first Symfony 5 tutorial. By using symfony console
- instead of running bin/console
directly - my commands will be able to talk to my Docker services... which for me is just a database. That's not needed for this command, but it will be for others.
Anyways, run this and create a new entity called Answer
. Let's give this a few basic properties like content
which will store the answer itself. Set this to a text
type: the string
type maxes out at 255 characters. Say "no" to nullable: that will make this column required in the database.
Let's also add a username
property, which will be a string. Eventually, in the security tutorial, we'll change this to be a relationship to a User
entity. Use the 255 length and make it not nullable.
Oh, and one more: a votes
property that's an integer
so that people can up vote and down vote this answer. Make this not nullable and... done! Hit enter one more time to finish.
// ... lines 1 - 2 | |
namespace App\Entity; | |
use App\Repository\AnswerRepository; | |
use Doctrine\ORM\Mapping as ORM; | |
/** | |
* @ORM\Entity(repositoryClass=AnswerRepository::class) | |
*/ | |
class Answer | |
{ | |
/** | |
* @ORM\Id | |
* @ORM\GeneratedValue | |
* @ORM\Column(type="integer") | |
*/ | |
private $id; | |
/** | |
* @ORM\Column(type="text") | |
*/ | |
private $content; | |
/** | |
* @ORM\Column(type="string", length=255) | |
*/ | |
private $username; | |
/** | |
* @ORM\Column(type="integer") | |
*/ | |
private $votes; | |
public function getId(): ?int | |
{ | |
return $this->id; | |
} | |
public function getContent(): ?string | |
{ | |
return $this->content; | |
} | |
public function setContent(string $content): self | |
{ | |
$this->content = $content; | |
return $this; | |
} | |
public function getUsername(): ?string | |
{ | |
return $this->username; | |
} | |
public function setUsername(string $username): self | |
{ | |
$this->username = $username; | |
return $this; | |
} | |
public function getVotes(): ?int | |
{ | |
return $this->votes; | |
} | |
public function setVotes(int $votes): self | |
{ | |
$this->votes = $votes; | |
return $this; | |
} | |
} |
Timestampable and Default votes Value
Before we generate the migration, go open up that class: src/Entity/Answer.php
. So far... there's nothing special here! It looks pretty much like our other entity. Oh, but if you're using PHP 8, then the command may have generated PHP 8 attributes instead of annotations. That's great! They work exactly the same and you should use attributes if you can.
At the top of the class, add use TimestampableEntity
. We talked about that in the last tutorial: it adds nice createdAt
and updatedAt
properties that will be set automatically.
// ... lines 1 - 6 | |
use Gedmo\Timestampable\Traits\TimestampableEntity; | |
// ... lines 8 - 11 | |
class Answer | |
{ | |
use TimestampableEntity; | |
// ... lines 15 - 77 | |
} |
Oh, and one other thing: default the votes to zero. I made this column not nullable in the database. Thanks to this = 0
, if we do not set the votes on a new answer, instead of getting a database error about null
not being allowed, the Answer
will save with votes = 0
.
// ... lines 1 - 11 | |
class Answer | |
{ | |
// ... lines 14 - 32 | |
/** | |
* @ORM\Column(type="integer") | |
*/ | |
private $votes = 0; | |
// ... lines 37 - 77 | |
} |
Making the Migration
Now let's generate the migration. Find your terminal and run:
symfony console make:migration
As a reminder, this command is smart: it looks at all of your entities and your actual database structure, and generates the SQL needed to make them match. Go check out that new file... it's in the migrations/
directory. And... perfect! CREATE TABLE answer
... and then it adds all of the columns.
// ... lines 1 - 2 | |
declare(strict_types=1); | |
namespace DoctrineMigrations; | |
use Doctrine\DBAL\Schema\Schema; | |
use Doctrine\Migrations\AbstractMigration; | |
/** | |
* Auto-generated Migration: Please modify to your needs! | |
*/ | |
final class Version20210902130926 extends AbstractMigration | |
{ | |
public function getDescription(): string | |
{ | |
return ''; | |
} | |
public function up(Schema $schema): void | |
{ | |
// this up() migration is auto-generated, please modify it to your needs | |
$this->addSql('CREATE TABLE answer (id INT AUTO_INCREMENT NOT NULL, content LONGTEXT NOT NULL, username VARCHAR(255) NOT NULL, votes INT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); | |
} | |
public function down(Schema $schema): void | |
{ | |
// this down() migration is auto-generated, please modify it to your needs | |
$this->addSql('DROP TABLE answer'); | |
} | |
} |
Run the migration with:
symfony console doctrine:migrations:migrate
All good! Our database now has a question
table and an answer
table. Next, let's relate them.
I'm trying to update my code that I've already created from previous S6 tutorial videos to align with the changes made ahead of this series to include stimulus and webpack encore. I believe I've added all of the dependecies I need and am running npm run watch to watch and bulid my webpack for my site. However, I'm getting the below error. I'm not familiar enough with nodejs to understand. Is it saying I'm missing webpack-encore? I've previously run composer require symfony/webpack-encore-bundle so that shouldn't be it. Also ran npm install encore just to be sure and am still getting the same issue.
`
root@2236ca7d93cd:/var/www/symfony_docker# npm run watch
node:internal/modules/cjs/loader:959
throw err;
^
Error: Cannot find module '../lib/config/parse-runtime'
Require stack:
at Module._resolveFilename (node:internal/modules/cjs/loader:956:15)
at Module._load (node:internal/modules/cjs/loader:804:27)
at Module.require (node:internal/modules/cjs/loader:1022:19)
at require (node:internal/modules/cjs/helpers:102:18)
at Object.<anonymous> (/var/www/symfony_docker/node_modules/.bin/encore:13:22)
at Module._compile (node:internal/modules/cjs/loader:1120:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1174:10)
at Module.load (node:internal/modules/cjs/loader:998:32)
at Module._load (node:internal/modules/cjs/loader:839:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12) {
code: 'MODULE_NOT_FOUND',
requireStack: [ '/var/www/symfony_docker/node_modules/.bin/encore' ]
}
Node.js v18.6.0
`
<b>UPDATE</b> I finally gave up trying to figure this out and just downloaded the sample file package, deleted my previous work, and started from the start directory. After running composr install, npm install and npm update, npm run watch is now working. I'm still curious on what this was if anybody has any thoughts.