Primeros pasos con Symfony 5
Sección: Symfony
Creado: 31-01-21 (Actualizado: 28-04-22)
Iniciamos un nuevo proyecto
Cuando iniciamos un proyecto en symfony 5 tenemos varias opcions. Las 2 más comunes son el proyecto mínimo y el completo.
Básico
sf new projectname [--version=5.0]
symfony composer req profiler --dev
symfony composer req logger
symfony composer req debug --dev
symfony composer req annotations
symfony composer req maker --dev
symfony composer req orm
Completo
sf new projectname --full [--version=5.0]
symfony composer req string
Permisos
sudo setfacl -dR -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var
sudo setfacl -R -m u:"$HTTPDUSER":rwX -m u:$(whoami):rwX var
Configuramos
# config/packages/framework.yaml
framework:
session:
handler_id: '%env(REDIS_URL)%'
ide: vscode://file/%%f:%%l&/var/www/html/>/home/manuel/projects/docker/nginx/sf/
# config/packages/doctrine.yaml
doctrine:
dbal:
dbname: '%env(DATABASE_NAME)%'
host: '%env(DATABASE_HOST)%'
#port: '%env(DATABASE_PORT)%'
user: '%env(DATABASE_USER)%'
password: '%env(DATABASE_PASSWORD)%'
#driver: '%env(DATABASE_DRIVER)%'
server_version: '%env(DATABASE_VERSION)%'
# url: '%env(resolve:DATABASE_URL)%'
#.env
DATABASE_NAME='db_name'
DATABASE_HOST='mariadb'
DATABASE_USER='root'
DATABASE_VERSION='mariadb-10.5.1'
REDIS_URL=redis://localhost:6379?timeout=5
sf console secrets:set DATABASE_PASSWORD
Modelo
symfony console make:entity Conference
* city,string,255,no
* year,string,4,no
* isInternational,boolean,no
* slug,string,255,no
symfony console make:entity Comment
* author,string,255,no
* text,text,no
* email,string,255,no
* photoFilename,strine,255,yes
* createdAt,datetime,no
symfony console make:entity Conference
* comments,OneToMany,Comment,no,yes
# src/entity/Conference.php
<?php
namespace App\Entity;
use App\Repository\ConferenceRepository;
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\String\Slugger\SluggerInterface;
/**
* @ORM\Entity(repositoryClass=ConferenceRepository::class)
* @UniqueEntity("slug")
*/
class Conference
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
private $city;
/**
* @ORM\Column(type="string", length=4)
*/
private $year;
/**
* @ORM\Column(type="boolean")
*/
private $isInternational;
/**
* @ORM\OneToMany(targetEntity=Comment::class, mappedBy="conference", orphanRemoval=true)
*/
private $comments;
/**
* @ORM\Column(type="string", length=255, unique=true)
*/
private $slug;
public function __toString(): string
{
return $this->city . ' ' . $this->year;
}
public function __construct()
{
$this->comments = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function computeSlug(SluggerInterface $slugger)
{
if (!$this->slug || '-' === $this->slug) {
$this->slug = (string) $slugger->slug((string) $this)->lower();
}
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(string $city): self
{
$this->city = $city;
return $this;
}
public function getYear(): ?string
{
return $this->year;
}
public function setYear(string $year): self
{
$this->year = $year;
return $this;
}
public function getIsInternational(): ?bool
{
return $this->isInternational;
}
public function setIsInternational(bool $isInternational): self
{
$this->isInternational = $isInternational;
return $this;
}
/**
* @return Collection|Comment[]
*/
public function getComments(): Collection
{
return $this->comments;
}
public function addComment(Comment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments[] = $comment;
$comment->setConference($this);
}
return $this;
}
public function removeComment(Comment $comment): self
{
if ($this->comments->removeElement($comment)) {
// set the owning side to null (unless already changed)
if ($comment->getConference() === $this) {
$comment->setConference(null);
}
}
return $this;
}
public function getSlug(): ?string
{
return $this->slug;
}
public function setSlug(string $slug): self
{
$this->slug = $slug;
return $this;
}
}
# src/entity/Comment.php
<?php
namespace App\Entity;
use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass=CommentRepository::class)
* @ORM\HasLifecycleCallbacks()
*/
class Comment
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank
*/
private $author;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank
*/
private $text;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank
* @Assert\Email
*/
private $email;
/**
* @ORM\Column(type="datetime")
*/
private $createdAt;
/**
* @ORM\ManyToOne(targetEntity=Conference::class, inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
*/
private $conference;
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $photoFilename;
public function getId(): ?int
{
return $this->id;
}
public function __toString(): string
{
return (string) $this->getEmail();
}
public function getAuthor(): ?string
{
return $this->author;
}
public function setAuthor(string $author): self
{
$this->author = $author;
return $this;
}
public function getText(): ?string
{
return $this->text;
}
public function setText(string $text): self
{
$this->text = $text;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
/**
* @ORM\PrePersist
*/
public function setCreatedAtValue(): self
{
$this->createdAt = new \DateTime();
return $this;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getConference(): ?Conference
{
return $this->conference;
}
public function setConference(?Conference $conference): self
{
$this->conference = $conference;
return $this;
}
public function getPhotoFilename(): ?string
{
return $this->photoFilename;
}
public function setPhotoFilename(?string $photoFilename): self
{
$this->photoFilename = $photoFilename;
return $this;
}
}
Doctrine Listener
# src/EntityListener/ConferenceEntityListener.php
namespace App\EntityListener;
use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;
class ConferenceEntityListener
{
private $slugger;
public function __construct(SluggerInterface $slugger)
{
$this->slugger=$slugger;
}
public function prePersist(Conference $conference, LifecycleEventArgs $event)
{
$conference->computeSlug($this->slugger);
}
public function preUpdate(Conference $conference, LifecycleEventArgs $event)
{
$conference->computeSlug($this->slugger);
}
}
# config/services.yml
App\EntityListener\ConferenceEntityListener:
tags:
- { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
- { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }
Migrate
symfony console doctrine:database:create
symfony console make:migration
symfony console doctrine:migrations:migrate
Entities class:
public function __toString(): string
{
return $this->city . ' ' . $this->year;
return (string) $this->getEmail();
Panel EasyAdmin
symfony composer req admin:^2
# config/packages/easy_admin.yaml
easy_admin:
site_name: Conference Guestbook
entities:
- App\Entity\Conference
- App\Entity\Comment
# config/packages/easy_admin.yaml
easy_admin:
site_name: Conference Guestbook
design:
menu:
- { route: 'homepage', label: 'Inicio', icon: 'home' }
- { entity: 'Conference', label: 'Conferences', icon: 'map-marker' }
- { entity: 'Comment', label: 'Comment', icon: 'comments' }
entities:
Conference:
class: App\Entity\Conference
Comment:
class: App\Entity\Comment
list:
fields:
- author
- { property: 'email', type: 'email' }
- { property: 'createdAt', type: 'datetime' }
sort: ['createdAt', 'ASC']
filters: ['conference' ]
edit:
fields:
- { property: 'conference' }
- { property: 'createdAt', type: datetime, type_options: { attr: { readonly: true }}}
- 'author'
- { property: 'email', type: 'email' }
- text
Controladores
# config/services.yaml
bind:
$photoDir: "%kernel.project_dir%/public/uploads/photos"
# src/Controller/ConferenceController.php
namespace App\Controller;
use App\Entity\Conference;
use App\Entity\Comment;
use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
class ConferenceController extends AbstractController
{
private $twig;
private $entityManager;
public function __construct(Environment $twig, EntityManagerInterface $entityManager)
{
$this->twig = $twig;
$this->entityManager = $entityManager;
}
/**
* @Route("/conference", name="conference_home")
*/
public function index(Environment $twig, ConferenceRepository $conferenceRepository): Response
{
return new Response($twig->render('conference/index.html.twig', [
'conferences' => $conferenceRepository->findAll(),
]));
}
/**
* @Route("/conference/{slug}", name="conference")
*/
public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
{
$comment = new Comment();
$form = $this->createForm(CommentFormType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$comment->setConference($conference);
if ($photo = $form['photo']->getData()) {
$filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
try {
$photo->move($photoDir, $filename);
} catch (FileException $e) {
// unable to upload the photo, give up
}
$comment->setPhotoFilename($filename);
}
$this->entityManager->persist($comment);
$this->entityManager->flush();
return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
}
$offset = max(0, $request->query->getInt('offset', 0));
$paginator = $commentRepository->getCommentPaginator($conference, $offset);
return new Response($twig->render('conference/show.html.twig', [
'conference' => $conference,
'comments' => $paginator,
'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
'comment_form' => $form->createView(),
// 'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
]));
}
}
Repositories
# ConferenceRepository.php
public function findAll()
{
return $this->findBy([], ['year' => 'ASC', 'city' => 'ASC']);
}
Paginator
# CommentRepository
use App\Entity\Conference;
use Doctrine\ORM\Tools\Pagination\Paginator;
public const PAGINATOR_PER_PAGE = 2;
public function getCommentPaginator(Conference $conference, int $offset):Paginator
{
$query = $this->createQueryBuilder('c')
->andWhere('c.conference = :conference')
->setParameter('conference', $conference)
->orderBy('c.createdAt', 'DESC')
->setMaxResults(self::PAGINATOR_PER_PAGE)
->setFirstResult($offset)
->getQuery()
;
return new Paginator($query);
}
Events
symfony console make:subscriber TwigEventSubscriber
# EventSubscriber/TwigEventSubscriber.php
use App\Repository\ConferenceRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Environment\Twig;
class TwigEventSubscriber implements EventSubscriberInterface
{
private $twig;
private $conferenceRepository;
public function __construct(Environment $twig, ConferenceRepository $conferenceRepository)
{
$this->twig = $twig;
$this->conferenceRepository = $conferenceRepository;
}
public function onControllerEvent(ControllerEvent $event)
{
$this->twig->addGlobal('conferences', $this->conferenceRepository->findAll());
}
public static function getSubscribedEvents()
{
return [
ControllerEvent::class => 'onControllerEvent',
];
}
}
Formularios
symfony console make:form CommentFormType Comment
# src/Form/CommentFormType.php
namespace App\Form;
use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Image;
class CommentFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('author', null, [
'label' => 'Your name'
])
->add('text')
->add('email', EmailType::class)
->add('photo', FileType::Class, [
'required' => false,
'mapped' => false,
'constraints' => [
new Image(['maxSize' => '1024k'])
],
])
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Comment::class,
]);
}
}
Vista
Al instalar twig tendremos base.html.twig, y las demás plantillas la extenderán.
symfony composer req "twig:^3"
Para usar algunos filtros (format_datetime) necesitaremos:
symfony composer req "twig/intl-extra:^3"
# base.html.twig
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
{# Run `composer require symfony/webpack-encore-bundle`
and uncomment the following Encore helpers to start using Symfony UX #}
{% block stylesheets %}
{# {{ encore_entry_link_tags('app') }} #}
{% endblock %}
{% block javascripts %}
{# {{ encore_entry_script_tags('app') }} #}
{% endblock %}
</head>
<body>
<header>
<h1><a href="{{ path("homepage") }}">Guestbook</a></h1>
<ul>
{% for conference in conferences %}
<li><a href="{{ path("conference_show", {'slug': conference.slug}) }}">{{ conference }}</a></li>
{% endfor %}
</ul>
<hr/>
</header>
{% block body %}{% endblock %}
</body>
</html>
# templates/conference/index.html
{% extends 'base.html.twig' %}
{% block title %}Conference Guestbook!{% endblock %}
{% block body %}
{% endblock %}
# templates/conference/show.html.twig
{% extends'base.html.twig'%}
{% block title %}Conference Guestbook - {{ conference }}{% endblock %}
{% block body %}
<h2>{{ conference }}Conference</h2>
<div>There are {{ comments|length }} comments.</div>
{% if comments | length > 0 %}
{% for comment in comments %}
{# {% if comment.photofilename %}
<img src="{{ asset('uploads/photos/' ~ comment.photofilename )}}" />
{% endif %} #}
<h4>{{ comment.author }}</h4>
<small>{{ comment.createdAt | format_datetime('medium','short') }}</small>
<p>{{ comment.text }}</p>
{% endfor %}
{% if previous >= 0 %}
<a href="{{ path('conference_show', { id: conference.slug, offset:previous }) }}">Previous</a>
{% endif %}
{% if next < comments|length %}
<a href="{{ path('conference_show', { id: conference.slug, offset: next}) }}">Next</a>
{% endif %}
{% else %}
<div>No comments have been posted yet for this conference.</div>
{% endif %}
<p>
<a href="{{ path("homepage") }}">Inicio</a>
</p>
{% endblock %}
Installing Encore
composer require symfony/webpack-encore-bundle
yarn install
create the assets/ directory
add a webpack.config.js file
add node_modules/ to .gitignore
Uncomment the Twig helpers in templates/base.html.twig
Start the development server:
yarn encore dev-server
Resumen
echo "date.timezone=Europe\Madrid"\n >> php.ini
echo 'intl.default_locale = "es_ES.UTF-8"\n' >> php.ini
Configuramos
sf new --version=5.2 --webapp --php=7.4 .
sf composer rem webpack
sf composer req admin:^2
sf composer req "twig/intl-extra"
DATABASE_URL
REDIS_URL=redis://localhost:6379?timeout=5
handler_id: '%env(REDIS_URL)%'
esquema
sf console make:entity Comment (author,text,email,createdAt, photoFilename)
sf console make:entity Conference (city,year,isInternational, slug, comments)
sf console make:migration
sf console doctrine:migrations:migrate
easy_admin.yaml
__toString()
controladores
sf console make:controller ConferenceController
modify ConferenceController::index (twig, conferenceRepository)
modify conference/index.html.twig
add ConferenceController::show
add conference/show.html.twig ( | format_datetime('medium', 'short'))
commentRepository::getCommentPaginator
ConferenceController::show(Request...) paginator, previous and next
conference/show.html.twig (count, previous, next)
Twig Event Subscriber
sf console make:subscriber TwigEventSubscriber (inject Twig and Conference)
$this->twig->addGlobal('var',val)
header en base.html.twig
ConferenceRepository finfAll findBy
Lifecycle
@ORM\HasLifecycleCallbacks()
--
@ORM\PrePersist
migrate database for slug compatibility
$this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
$this->addSql"UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
$this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
@UniqueEntity("slug")
unique=true
Conference::computeSlug(SluggerInterface $slugger)
namespace App\EntityListener;
class ConferenceEntityListener
# config/services.yml
App\EntityListener\ConferenceEntityListener:
tags:
- { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
- { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }
refactor (conference.slug)
# src/EntityListener/ConferenceEntityListener.php
namespace App\EntityListener;
use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;
class ConferenceEntityListener
{
private $slugger;
public function __construct(SluggerInterface $slugger)
{
$this->slugger=$slugger;
}
public function prePersist(Conference $conference, LifecycleEventArgs $event)
{
$conference->computeSlug($this->slugger);
}
public function preUpdate(Conference $conference, LifecycleEventArgs $event)
{
$conference->computeSlug($this->slugger);
}
}
# config/services.yml
App\EntityListener\ConferenceEntityListener:
tags:
- { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference' }
- { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference' }
forms
sf console make:form ConferenceTypeForm Comment
customize
ConferenceController (new Comment and form)
show.html.twig {{ form(comment_form) }}
Comment entity (asserts)
use Symfony\Component\Validator\Constraints as Assert;
Handling the form
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
$comment->setConference($conference);
if($photo = $form['photo']->getData()) {
$filename = bin2hex(random_bytes(6)) . '.' . $photo->guessExtension();
try {
$photo->move($photoDir, $filename);
} catch (FileException $e) {
}
$comment->setPhotoFilename($filename);
}
$commentRepository->add($comment, true);
return $this->redirectToRoute("conference", ['slug' => $conference->getSlug()]);
}
_defaults:
bind:
string $photoDir: "%kernel.project_dir%/public/uploads/"
.gitignore
/public/uploads
securing the admin
sf console make:user Admin
public function __toString(): string
{
return $this->username;
}
migrate
symfony console security:encode-password
symfony run psql -c "INSERT INTO admin (id, username, roles, password) \
VALUES (nextval('admin_id_seq'), 'admin', '[\"ROLE_ADMIN\"]', \
'\__ENCODEDPASSWORD__')"
sf console make:auth
onAuthenticationSuccess()
return new RedirectResponse($this->urlGenerator->generate('easyadmin'));
- { path: ^/admin, roles: ROLE_ADMIN }
Tests
sf composer req phpunit --dev
sf composer require browser-kit --dev
sf console make:test [TestCase SpamCheckerTest]
sf php bin/phpunit
APP_ENV=test sf console secrets:set AKISMET_KEY
sf run bin/phpunit tests/Controller/ConferenceControllerTest.php
$client->getResponse() y echo
Fixtures
sf composer req orm-fixtures --dev
sf console doctrine:fixtures:load --env=test