Any site has forms, from the simple contact form to the complex ones with lots of fields. Writing forms is also one of the most complex and tedious task for a web developer: you need to write the HTML form, implement validation rules for each field, process the values to store them in a database, display error messages, repopulate fields in case of errors, and much more…
To deal with creation of jobs we will need forms, that in Symfony are realized by Form Component.
The Form component allows you to easily create, process and reuse forms.
We will create a class that generally is Form Builder
class. There we will define fields, that form should have, validation rules and many other things.
So, let’s create a folder src/Form
where all forms will be placed. In the folder we are going to create our first form in file JobType.php
:
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class JobType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
}
}
As you can notice, our class extends AbstractType
class and usually you will have to work with buildForm
and configureOptions
methods.
That’s why we defined them from the beginning.
Every form needs to know the name of the class that holds the underlying data.
Let’s specify the data_class
option by changing configureOptions
method:
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Job::class,
]);
}
Note: don’t forget to import Job
class
Now we should define form fields in buildForm
method:
// ....
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
class JobType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('type', TextType::class)
->add('company', TextType::class)
->add('logo', TextType::class)
->add('url', UrlType::class)
->add('position', TextType::class)
->add('location', TextType::class)
->add('description', TextareaType::class)
->add('howToApply', TextType::class)
->add('public', TextType::class)
->add('activated', TextType::class)
->add('email', EmailType::class)
->add('category', TextType::class)
->add('token', TextType::class);
}
// ...
}
We call method add
on form builder and add all fields one by one. First argument is the field name which is actually a property in the entity.
The second argument is the field type. In our case we used TextType, EmailType and DateTimeType. And 3rd parameter is optional, we do not use it for now.
Field type affects rendering of field and also provides different options for configuration.
Example: TextType
will be rendered as <input type="text" ...>
Let’s try other types. For example, we defined public
field as TextType
, but in entity it is boolean.
It’s better to render it as selector with YES and NO options. We will implement ChoiceType:
- ->add('public', TextType::class)
+ ->add('public', ChoiceType::class, [
+ 'choices' => [
+ 'Yes' => true,
+ 'No' => false,
+ ],
+ 'label' => 'Public?',
+ ])
We changed type and also defined next options: choices
- items, that will be used as <options>
and label
.
Do the same thing with activated
field too.
Also category
is TextType
, but we have categories in DB and it would be good to render selector with these options.
It looks like it should be ChoiceType
, but choice from entities is specific case, and we have separate type for it: EntityType.
It extends ChoiceType
but with some additional options related to DB.
- ->add('category', TextType::class)
+ ->add('category', EntityType::class, [
+ 'class' => Category::class,
+ 'choice_label' => 'name',
+ ])
We specified choice_label
to select a field from entity that will be shown as option in selector.
We changed only one line of code, but in template instead of simple input we will have select with all categories in options.
Now let’s change type
field. For now, it’s text field, but in the second day’s description we have next requirement:
Type (full-time, part-time, or freelance)
We need defined list of options and let’s do it in Job
entity:
class Job
{
public const FULL_TIME_TYPE = 'full-time';
public const PART_TIME_TYPE = 'part-time';
public const FREELANCE_TYPE = 'freelance';
public const TYPES = [
self::FULL_TIME_TYPE,
self::PART_TIME_TYPE,
self::FREELANCE_TYPE,
];
// ...
}
Currently we can change the form type of type
field to ChoiceType
:
- ->add('type', TextType::class)
+ ->add('type', ChoiceType::class, [
+ 'choices' => array_combine(Job::TYPES, Job::TYPES),
+ 'expanded' => true,
+ ])
Why we used array_combine
but not directly Job::TYPES
? We want to show options with same label and value.
For example, option with label freelance
should have value freelance
(<option value="freelance">freelance</option>
) and array_combine
helps us to do that.
Also expanded
is true to show you how different ChoiceType
can be rendered. We will see it later.
Let’s add some labels and options. The final result should be:
namespace App\Form;
use App\Entity\Category;
use App\Entity\Job;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class JobType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('type', ChoiceType::class, [
'choices' => array_combine(Job::TYPES, Job::TYPES),
'expanded' => true,
])
->add('company', TextType::class)
->add('logo', TextType::class)
->add('url', UrlType::class)
->add('position', TextType::class)
->add('location', TextType::class)
->add('description', TextareaType::class)
->add('howToApply', TextType::class, [
'label' => 'How to apply?',
])
->add('public', ChoiceType::class, [
'choices' => [
'Yes' => true,
'No' => false,
],
'label' => 'Public?',
])
->add('activated', ChoiceType::class, [
'choices' => [
'Yes' => true,
'No' => false,
],
])
->add('email', EmailType::class)
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
])
->add('token', TextType::class);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Job::class,
]);
}
}
We have just created JobType
form class. The next step is to create and render actual form.
In Symfony, this is done by building a form object and then rendering it in a template.
For now, we will create new action inside the JobController
controller:
// ...
use App\Form\JobType;
class JobController extends AbstractController
{
// ...
/**
* Creates a new job entity.
*
* @Route("/job/create", name="job.create", methods="GET")
*
* @return Response
*/
public function create() : Response
{
$job = new Job();
$form = $this->createForm(JobType::class, $job);
return $this->render('job/create.html.twig', [
'form' => $form->createView(),
]);
}
}
We created new Job object and passed it to createForm
method along with JobType
class.
This method will create a form class based on entity object and rules described in JobType
class.
In form we will need a special form "view" object, that’s why we passed to template not the form, but the result of createView
method.
In the previous step we passed data to job/create.html.twig
template, but we don’t have it yet.
Let’s create it and use a set of form helper functions:
{% extends 'base.html.twig' %}
{% block body %}
<h1>Job creation</h1>
{{ form_start(form) }}
{{ form_widget(form) }}
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">Create</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}
That’s it! Form will be rendered due to:
form_start
- renders<form>
tag with all needed attributed (method, encryption, etc).form_widget
- renders all the fields, labels and any validation error messages.form_end
- renders</form>
tag.
Open the browser and access /job/create
path to see how form is rendered.
The form with all fields are rendered, but the styling is not the same as in bootstrap.
The good news is that Twig Bridge component, that is responsible for integration of Twig in Symfony, comes with some themes out of the box.
We use bootstrap 3 and will choose bootstrap_3_horizontal_layout.html.twig
theme file. Let’s setup it in config/packages/twig.yaml
:
twig:
{# ... #}
form_themes:
- 'bootstrap_3_horizontal_layout.html.twig'
Refresh the page. Now form should look in bootstrap 3 style.
Also you may notice that we wrote HTML for submit button and did’t use SubmitType. It’s a good practice because form become more reusable. Read more here.
It’s the time to link the button "Post a Job" with created action (templates/base.html.twig
):
- <a href="#" class="btn btn-default navbar-btn">Post a Job</a>
+ <a href="{{ path('job.create') }}" class="btn btn-default navbar-btn">Post a Job</a>
Form is built and rendered. Processing is next. If you submit the form, nothing happens. Let’s fix it:
class JobController extends AbstractController
{
// ...
/**
* Creates a new job entity.
*
* @Route("/job/create", name="job.create", methods={"GET", "POST"})
*
* @param Request $request
* @param EntityManagerInterface $em
*
* @return RedirectResponse|Response
*/
public function create(Request $request, EntityManagerInterface $em) : Response
{
$job = new Job();
$form = $this->createForm(JobType::class, $job);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($job);
$em->flush();
return $this->redirectToRoute('job.list');
}
return $this->render('job/create.html.twig', [
'form' => $form->createView(),
]);
}
}
- we added
POST
method to annotation, because form will be submitted with this method. - request object is added to method arguments because in request will be data of submitted form
- entity manager is added to method arguments because we will use it to store new job
handleRequest
method maps request data to formisSubmitted
method checks if form was submitted or method is called with get method, and we need only to show the formisValid
method tracks if all requirements are met (we will add them in next step)redirectToRoute
is used to redirect to list of jobs and to prevent repeated submit of form (CTRL + R)
For now form is built, rendered and processed, but we don’t validate any information. Form accepts anything. We should add some validation rules.
Validation rules are called
constraints
in symfony forms.
There are two places where constraints can be added: in annotations of entity or in form class. We will choose the second option, to keep the logic of form validation in form class and not to enlarge entity class.
Our first field in form is type
field. According to entity this field should be required:
->add('type', ChoiceType::class, [
'choices' => array_combine(Job::TYPES, Job::TYPES),
'expanded' => true,
+ 'constraints' => [
+ new NotBlank(),
+ ]
])
and import Symfony\Component\Validator\Constraints\NotBlank
class.
Next field is company
and it’s required text field with a maximum length of 255 characters:
->add('company', TextType::class, [
+ 'constraints' => [
+ new NotBlank(),
+ new Length(['max' => 255]),
+ ]
])
Try to submit more than 255 characters and you will see error:
Symfony has a big list of constraints out of the box. You can find all of them here. Review all fields and add relevant constraints and finally you should see something similar:
namespace App\Form;
use App\Entity\Category;
use App\Entity\Job;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
class JobType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('type', ChoiceType::class, [
'choices' => array_combine(Job::TYPES, Job::TYPES),
'expanded' => true,
'constraints' => [
new NotBlank(),
]
])
->add('company', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(['max' => 255]),
]
])
->add('logo', TextType::class)
->add('url', UrlType::class, [
'required' => false,
'constraints' => [
new Length(['max' => 255]),
]
])
->add('position', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(['max' => 255]),
]
])
->add('location', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(['max' => 255]),
]
])
->add('description', TextareaType::class, [
'constraints' => [
new NotBlank(),
]
])
->add('howToApply', TextType::class, [
'label' => 'How to apply?',
'constraints' => [
new NotBlank(),
]
])
->add('public', ChoiceType::class, [
'choices' => [
'Yes' => true,
'No' => false,
],
'label' => 'Public?',
'constraints' => [
new NotNull(),
]
])
->add('activated', ChoiceType::class, [
'choices' => [
'Yes' => true,
'No' => false,
],
'constraints' => [
new NotNull(),
]
])
->add('email', EmailType::class, [
'constraints' => [
new NotBlank(),
new Email()
]
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'constraints' => [
new NotBlank(),
]
])
->add('token', TextType::class, [
'constraints' => [
new NotBlank(),
new Length(['max' => 255]),
]
]);
}
// ...
}
You may have noticed that we used 'required' => false
and new NotBlank()
constraint. What is the difference?
By default, all fields have required
set to true and this option affects only the rendering of the field, it adds required
to HTML field tag:
<input type="text" name="company" required>
But this "requirement" can be easy bypassed by developer tools provided by every browser.
NotBlank
constraint checks on the level of form processing and can’t be bypassed.
If you want to test fully the power of constraints or simply want to disable browser validation add next parameter to form_start
function in template:
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
Also note that there are NotNull
and NotBlank
validations. With choices where Yes/No are answers it is better to use NotNull
, because NotBlank
and false option will give validation error.
To handle the actual file upload in the form, we will need to change the logo field type to FileType
in the form:
->add('logo', FileType::class, [
'required' => false,
])
Also we need to add an Image
constraint:
->add('logo', FileType::class, [
'required' => false,
'constraints' => [
new Image(),
]
])
When the form is submitted, the logo field will be an instance of UploadedFile
.
It can be used to move the file to a permanent location. After this we will set the job logo property to the uploaded filename.
For this to work we need to add a new parameter, jobs_directory
, in config/services.yaml
file:
parameters:
# ...
jobs_directory: '%kernel.project_dir%/public/uploads/jobs'
And to add the processing logic in JobController
:
// ...
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class JobController extends Controller
{
// ...
public function create(Request $request, EntityManagerInterface $em) : Response
{
// ...
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile|null $logoFile */
$logoFile = $form->get('logo')->getData();
if ($logoFile instanceof UploadedFile) {
$fileName = \bin2hex(\random_bytes(10)) . '.' . $logoFile->guessExtension();
// moves the file to the directory where brochures are stored
$logoFile->move(
$this->getParameter('jobs_directory'),
$fileName
);
$job->setLogo($fileName);
}
$em->persist($job);
$em->flush();
return $this->redirectToRoute('job.list');
}
// ...
}
}
Note that now JobController
extends Controller
and not AbstractController
because we need getParameter
method.
Even if this implementation works, let’s do this in a better way, moving logic to service and using Doctrine lifecycle callbacks.
To create a service, first create a new FileUploader
class in src/Service
folder:
namespace App\Service;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class FileUploader
{
/** @var string */
private $targetDirectory;
/**
* @param string $targetDirectory
*/
public function __construct(string $targetDirectory)
{
$this->targetDirectory = $targetDirectory;
}
/**
* @return string
*/
public function getTargetDirectory(): string
{
return $this->targetDirectory;
}
/**
* @param UploadedFile $file
*
* @return string
*/
public function upload(UploadedFile $file) : string
{
$fileName = md5(uniqid()) . '.' . $file->guessExtension();
$file->move($this->targetDirectory, $fileName);
return $fileName;
}
}
Then, define a service for this class in config/services.yaml
:
# ...
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
public: false # Allows optimizing the container by removing unused services; this also means
# fetching services directly from the container via $container->get() won't work.
# The best practice is to be explicit about your dependencies anyway.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{Entity,Migrations,Tests,Kernel.php}'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller'
tags: ['controller.service_arguments']
+ App\Service\FileUploader:
+ arguments:
+ $targetDirectory: '%jobs_directory%'
Important! All custom definitions of services should be added at the end of the file, after
_defaults:
,App\:
andApp\Controller\:
definitions.
Otherwise it will create unexpected overriding of the services and unexpected behavior of Dependency Injection.
If you are not familiar with "service" definition, then we advice you to read excellent article written by Symfony team: Service Container.
Now you’re ready to use this service in the controller:
// ...
use App\Service\FileUploader;
class JobController extends AbstractController
{
// ...
public function create(Request $request, EntityManagerInterface $em, FileUploader $fileUploader) : Response
{
// ...
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile|null $logoFile */
$logoFile = $form->get('logo')->getData();
if ($logoFile instanceof UploadedFile) {
$fileName = $fileUploader->upload($logoFile);
$job->setLogo($fileName);
}
$em->persist($job);
$em->flush();
return $this->redirectToRoute('job.list');
}
// ...
}
}
Now create a Doctrine listener to automatically upload the file when persisting the entity (src/EventListener/JobUploadListener.php
):
namespace App\EventListener;
use App\Entity\Job;
use App\Service\FileUploader;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
class JobUploadListener
{
/** @var FileUploader */
private $uploader;
/**
* @param FileUploader $uploader
*/
public function __construct(FileUploader $uploader)
{
$this->uploader = $uploader;
}
/**
* @param LifecycleEventArgs $args
*/
public function prePersist(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
$this->uploadFile($entity);
}
/**
* @param PreUpdateEventArgs $args
*/
public function preUpdate(PreUpdateEventArgs $args)
{
$entity = $args->getEntity();
$this->uploadFile($entity);
}
/**
* @param $entity
*/
private function uploadFile($entity)
{
// upload only works for Job entities
if (!$entity instanceof Job) {
return;
}
$logoFile = $entity->getLogo();
// only upload new files
if ($logoFile instanceof UploadedFile) {
$fileName = $this->uploader->upload($logoFile);
$entity->setLogo($fileName);
}
}
}
Next, register this class as a Doctrine listener in config/services.yaml
:
# ...
services:
# ...
App\EventListener\JobUploadListener:
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }
This listener is now automatically executed when persisting a new Job entity. This way, you can remove everything related to uploading from the controller.
Note that this will not work, until methods setLogo
and getLogo
from Job
entity are forced to work with string.
Remove this constraint and it will work:
/**
* @return string|null|UploadedFile
*/
public function getLogo()
{
return $this->logo;
}
/**
* @param string|null|UploadedFile $logo
*
* @return self
*/
public function setLogo($logo) : self
{
$this->logo = $logo;
return $this;
}
We uploaded a file and should show it. Add parameter variable in config/services.yaml
that will be used to find a path to image:
parameters:
# ...
jobs_web_directory: '/uploads/jobs'
mark it as global twig variable in config/packages/twig.yaml
:
twig:
# ...
globals:
# ...
jobs_web_directory: '%jobs_web_directory%'
and add image block in templates/job/show.html.twig
:
{% extends 'base.html.twig' %}
{% block body %}
<h1>Job</h1>
<div class="media" style="margin-top: 60px;">
{% if job.logo %}
<div class="media-left">
<a href="{{ job.url }}" target="_blank">
<img class="media-object" style="width:100px; height:100px;" src="{{ asset(jobs_web_directory ~ '/' ~ job.logo.filename) }}">
</a>
</div>
{% endif %}
{# ... #}
Everything must work fine by now. As of now, the user must enter the token for the job. But the job token must be generated automatically when a new job is created, as we don’t want to rely on the user to provide a unique token.
Create a new listener JobTokenListener.php
in src/EventListener
folder to add the logic that generates the token before a new job is saved:
namespace App\EventListener;
use App\Entity\Job;
use Doctrine\ORM\Event\LifecycleEventArgs;
class JobTokenListener
{
/**
* @param LifecycleEventArgs $args
*/
public function prePersist(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
if (!$entity instanceof Job) {
return;
}
if (!$entity->getToken()) {
$entity->setToken(\bin2hex(\random_bytes(10)));
}
}
}
define it in config/services.yaml
:
#...
services:
# ...
App\EventListener\JobTokenListener:
tags:
- { name: doctrine.event_listener, event: prePersist }
Now you can remove the token field from the form by deleting the add(‘token’)
line.
If you remember the user stories from day 2, a job can be edited only if the user knows the associated token.
Take it in consideration and create new action in JobController
:
// ...
class JobController extends Controller
{
// ...
/**
* Edit existing job entity
*
* @Route("/job/{token}/edit", name="job.edit", methods={"GET", "POST"}, requirements={"token" = "\w+"})
*
* @param Request $request
* @param Job $job
* @param EntityManagerInterface $em
*
* @return Response
*/
public function edit(Request $request, Job $job, EntityManagerInterface $em) : Response
{
$form = $this->createForm(JobType::class, $job);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
return $this->redirectToRoute('job.list');
}
return $this->render('job/edit.html.twig', [
'form' => $form->createView(),
]);
}
}
This action is very similar to create action but there are:
- another path and name of the route
- job object is pre-populated to the method
- we build form based on this job object, not the new one
- another template
- we do not call
persist
, because job object was already persisted
Next step is to create template that we called in action:
{% extends 'base.html.twig' %}
{% block body %}
<h1>Job edit</h1>
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_widget(form) }}
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default">Edit</button>
</div>
</div>
{{ form_end(form) }}
{% endblock %}
Now add a link to this page in templates/job/show.html.twig
template:
- <a class="btn btn-primary" href="#">
+ <a class="btn btn-primary" href="{{ path('job.edit', {token: job.token}) }}">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit
</a>
Right now if you will try to access this page you will get an error because logo
field is string.
The form needs File
object.
We can fix it by listening post loading
event and replacing string by expected object:
// ...
use Symfony\Component\HttpFoundation\File\File;
class JobUploadListener
{
// ...
/**
* @param PreUpdateEventArgs $args
*/
public function preUpdate(PreUpdateEventArgs $args)
{
$entity = $args->getEntity();
$this->uploadFile($entity);
$this->fileToString($entity);
}
/**
* @param LifecycleEventArgs $args
*/
public function postLoad(LifecycleEventArgs $args)
{
$entity = $args->getEntity();
$this->stringToFile($entity);
}
/**
* @param $entity
*/
private function stringToFile($entity)
{
if (!$entity instanceof Job) {
return;
}
if ($fileName = $entity->getLogo()) {
$entity->setLogo(new File($this->uploader->getTargetDirectory() . '/' . $fileName));
}
}
/**
* @param $entity
*/
private function fileToString($entity)
{
if (!$entity instanceof Job) {
return;
}
$logoFile = $entity->getLogo();
if ($logoFile instanceof File) {
$entity->setLogo($logoFile->getFilename());
}
}
}
add define this method in config/services.yaml
:
# ...
services:
# ...
App\EventListener\JobUploadListener:
tags:
- { name: doctrine.event_listener, event: prePersist }
- { name: doctrine.event_listener, event: preUpdate }
+ - { name: doctrine.event_listener, event: postLoad }
Now it works!
The preview page is the same as the job page display. The only difference is that the job preview page will be accessed using the job token instead of the job id:
// ...
class JobController extends AbstractController
{
// ...
/**
* Finds and displays the preview page for a job entity.
*
* @Route("job/{token}", name="job.preview", methods="GET", requirements={"token" = "\w+"})
*
* @param Job $job
*
* @return Response
*/
public function preview(Job $job) : Response
{
return $this->render('job/show.html.twig', [
'job' => $job,
]);
}
}
If the user comes in with the tokenized URL, we will add an admin bar at the top. To notify template about this condition we will pass additional variable from action:
return $this->render('job/show.html.twig', [
'job' => $job,
+ 'hasControlAccess' => true,
]);
At the beginning of the show.html.twig
template, include a template to host the admin bar and remove the edit link at the bottom:
{% extends 'base.html.twig' %}
{% block body %}
+ {% if hasControlAccess is defined and hasControlAccess %}
+ {% include 'job/control_panel.html.twig' with {'job': job} %}
+ {% endif %}
<h1>Job</h1>
{# ... #}
- <a class="btn btn-primary" href="{{ path('job.edit', { 'token': job.token }) }}">
- <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
- Edit
- </a>
{# ... #}
Then, create the control_panel.html.twig
template:
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<span class="navbar-brand">Control Panel:</span>
</div>
<div class="collapse navbar-collapse">
{% if job.activated %}
{% if job.expiresAt < date() %}
<p class="navbar-text ">Expired</p>
{% else %}
<p class="navbar-text ">Expires in <strong>{{ job.expiresAt.diff(date())|date('%a') }}</strong> days</p>
{% endif %}
{% if job.expiresAt.diff(date())|date('%a') < 5 %}
<a class="btn btn-default navbar-btn" href="#">
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span>
Extend (for another 30 days)
</a>
{% endif %}
{% else %}
<a class="btn btn-default navbar-btn" href="{{ path('job.edit', { 'token': job.token }) }}">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit
</a>
<a class="btn btn-default navbar-btn" href="#">
<span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
Publish
</a>
{% endif %}
<p class="navbar-text navbar-right">
[Bookmark this <a href="{{ url('job.preview', {token: job.token}) }}">URL</a> to manage this job in the future]
</p>
</div>
</div>
</nav>
There is a lot of code, but we need to focus only on one: we used url
function and not path
.
The one difference between these two functions is that url
generates absolute URL (with scheme and host) and path
generates relative URL.
The admin bar displays the different actions depending on the job status:
We now need redirect from create
and edit
actions of the JobController
to the new preview page:
return $this->redirectToRoute(
'job.preview',
['token' => $job->getToken()]
);
User, who created a job offer should also be able to delete it. Create a method that will build a form with delete functionality:
// ...
use Symfony\Component\Form\FormInterface;
class JobController extends AbstractController
{
// ...
/**
* Creates a form to delete a job entity.
*
* @param Job $job
*
* @return FormInterface
*/
private function createDeleteForm(Job $job) : FormInterface
{
return $this->createFormBuilder()
->setAction($this->generateUrl('job.delete', ['token' => $job->getToken()]))
->setMethod('DELETE')
->getForm();
}
}
Notice that we built a form without creating separate class for it
Call this method in preview
and pass it to template:
public function preview(Job $job) : Response
{
+ $deleteForm = $this->createDeleteForm($job);
return $this->render('job/show.html.twig', [
'job' => $job,
'hasControlAccess' => true,
+ 'deleteForm' => $deleteForm->createView(),
]);
}
Transmit this variable from show.html.twig
to control_panel.html.twig
:
- {% include 'job/control_panel.html.twig' with {'job': job} %}
+ {% include 'job/control_panel.html.twig' with {'job': job, 'deleteForm': deleteForm} %}
and render a form in control panel:
<nav class="navbar navbar-default">
<div class="container-fluid">
<div class="navbar-header">
<span class="navbar-brand">Control Panel:</span>
</div>
<div class="collapse navbar-collapse">
{{ form_start(deleteForm, {'attr': {'class': 'navbar-form navbar-left'}}) }}
{{ form_widget(deleteForm) }}
<button type="submit" class="btn btn-default" onclick="return confirm('Are you sure?')">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete
</button>
{{ form_end(deleteForm) }}
{# ... #}
This form will call job.delete
, but we don’t have it yet. Create this action:
// ...
class JobController extends AbstractController
{
// ...
/**
* Delete a job entity.
*
* @Route("job/{token}/delete", name="job.delete", methods="DELETE", requirements={"token" = "\w+"})
*
* @param Request $request
* @param Job $job
* @param EntityManagerInterface $em
*
* @return Response
*/
public function delete(Request $request, Job $job, EntityManagerInterface $em) : Response
{
$form = $this->createDeleteForm($job);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->remove($job);
$em->flush();
}
return $this->redirectToRoute('job.list');
}
}
In The Preview Page
section link was defined to publish the job. The link needs to be changed to point to the new publish action:
// ...
class JobController extends AbstractController
{
// ...
/**
* Publish a job entity.
*
* @Route("job/{token}/publish", name="job.publish", methods="POST", requirements={"token" = "\w+"})
*
* @param Request $request
* @param Job $job
* @param EntityManagerInterface $em
*
* @return Response
*/
public function publish(Request $request, Job $job, EntityManagerInterface $em) : Response
{
$form = $this->createPublishForm($job);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$job->setActivated(true);
$em->flush();
$this->addFlash('notice', 'Your job was published');
}
return $this->redirectToRoute('job.preview', [
'token' => $job->getToken(),
]);
}
/**
* Creates a form to publish a job entity.
*
* @param Job $job
*
* @return FormInterface
*/
private function createPublishForm(Job $job) : FormInterface
{
return $this->createFormBuilder(['token' => $job->getToken()])
->setAction($this->generateUrl('job.publish', ['token' => $job->getToken()]))
->setMethod('POST')
->getForm();
}
}
We also need to change preview
action to send publish form to the template:
public function preview(Job $job) : Response
{
$deleteForm = $this->createDeleteForm($job);
+ $publishForm = $this->createPublishForm($job);
return $this->render('job/show.html.twig', [
'job' => $job,
'hasControlAccess' => true,
'deleteForm' => $deleteForm->createView(),
+ 'publishForm' => $publishForm->createView(),
]);
}
And also transmit this variable from show.html.twig
to control_panel.html.twig
:
- {% include 'job/control_panel.html.twig' with {'job': job, 'deleteForm': deleteForm} %}
+ {% include 'job/control_panel.html.twig' with {
+ 'job': job,
+ 'deleteForm': deleteForm,
+ 'publishForm': publishForm
+ } only %}
We can now change URL of the "Publish" link. We will use a form here, like when deleting a job, so we will have a POST request:
- <a class="btn btn-default navbar-btn" href="#">
- <span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
- Publish
- </a>
+ {{ form_start(publishForm, {'attr': {'class': 'navbar-form navbar-left'}}) }}
+ {{ form_widget(publishForm) }}
+
+ <button type="submit" class="btn btn-default">
+ <span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
+ Publish
+ </button>
+ {{ form_end(publishForm) }}
Also you may have noticed that we called addFlash
method in controller. It’s a storage for special messages.
We added message and now let’s display it in show.html.twig
:
{# ... #}
{% for message in app.flashes('notice') %}
<div class="alert alert-success" role="alert">
{{ message }}
</div>
{% endfor %}
<h1>Job</h1>
{# ... #}
Message is deleted from storage right after displaying. That’s why after refresh it will not be shown.
You can now test the new publish feature in your browser.
But we still have something to fix. The non-activated jobs must not be accessible, which means that they must not show up on the Jobeet homepage, and must not be accessible by their URL.
We need to edit the JobRepository
methods to add this requirement:
// ...
class JobRepository extends EntityRepository
{
/**
* @param int|null $categoryId
*
* @return Job[]
*/
public function findActiveJobs(int $categoryId = null)
{
$qb = $this->createQueryBuilder('j')
->where('j.expiresAt > :date')
->andWhere('j.activated = :activated')
->setParameter('date', new \DateTime())
->setParameter('activated', true)
->orderBy('j.expiresAt', 'DESC');
if ($categoryId) {
$qb->andWhere('j.category = :categoryId')
->setParameter('categoryId', $categoryId);
}
return $qb->getQuery()->getResult();
}
/**
* @param int $id
*
* @throws NonUniqueResultException
*
* @return Job|null
*/
public function findActiveJob(int $id) : ?Job
{
return $this->createQueryBuilder('j')
->where('j.id = :id')
->andWhere('j.expiresAt > :date')
->andWhere('j.activated = :activated')
->setParameter('id', $id)
->setParameter('date', new \DateTime())
->setParameter('activated', true)
->getQuery()
->getOneOrNullResult();
}
/**
* @param Category $category
*
* @return AbstractQuery
*/
public function getPaginatedActiveJobsByCategoryQuery(Category $category) : AbstractQuery
{
return $this->createQueryBuilder('j')
->where('j.category = :category')
->andWhere('j.expiresAt > :date')
->andWhere('j.activated = :activated')
->setParameter('category', $category)
->setParameter('date', new \DateTime())
->setParameter('activated', true)
->getQuery();
}
}
The same for CategoryRepository
:
// ...
class CategoryRepository extends EntityRepository
{
/**
* @return Category[]
*/
public function findWithActiveJobs()
{
return $this->createQueryBuilder('c')
->select('c')
->innerJoin('c.jobs', 'j')
->where('j.expiresAt > :date')
->andWhere('j.activated = :activated')
->setParameter('date', new \DateTime())
->setParameter('activated', true)
->getQuery()
->getResult();
}
}
And Category
entity:
// ...
class Category
{
// ...
public function getActiveJobs()
{
return $this->jobs->filter(function(Job $job) {
return $job->getExpiresAt() > new \DateTime() && $job->isActivated();
});
}
// ...
}
That’s all. You can test it now in your browser. All non-activated jobs have disappeared from the homepage; even if you know their URLs, they are not accessible anymore. They are, however, accessible if one knows the job’s token URL. In that case, the job preview will show up with the admin bar.
That’s all for today, you can find the code here: https://github.com/gregurco/jobeet/tree/day8
See you tomorrow!
- Forms
- Form Component
- How to Upload Files
- How to Customize Form Rendering
- Form Types Reference
- Securely Generating Random Values
- Flash Messages
- Service Container
Continue this tutorial here: Jobeet Day 9: Console Commands
Previous post is available here: Jobeet Day 7: Playing with the Category Page
Main page is available here: Symfony 4.2 Jobeet Tutorial