In Symfony, Service classes generally are used to hold code that performs repeatable tasks. For example, say you needed to format a phone number. You would make a service class with a method that formats the phone number as opposed to copying and pasting the same bit of code around each time you needed to format a phone number. Services are also a good place to store your business logic code. Code that doesn’t really belong in your controller, entity or repository classes.
So far we’ve built out an API which lets a user register, login and see their account details. While everything works great as is, it would be better to store some of the code that we’ve written so far in services. That way our code is more organized, reusable and testable.
Today we’ll be moving the code that’s in our /register route into a few separate services to thin up our controller. For that we’ll make a service for our User entity. It will handle creating, saving and validating an entity.
The second thing we’ll work on is making a service for our JWT authenticator. If you’ve followed along from the last tutorial, when someone logs in they will be automatically logged out once the cookie expires. We’ll create a service that handles refreshing the JWT cookie as well as create some wrapper functions around the encoding and decoding of a JWT payload to make that process more repeatable.
Let’s get started!
Step 1: Validation Service
To start we need to install Symfony’s validation service. Open a terminal and navigate to your project’s directory and type:
composer require validation
Now open your UserController.php file and we will start to slim up our register controller. Currently it should look something like this:
public function register(ObjectManager $om, UserPasswordEncoderInterface $passwordEncoder, Request $request) { $user = new User(); $email = $request->request->get("email"); $password = $request->request->get("password"); $passwordConfirmation = $request->request->get("password_confirmation"); $errors = []; if($password != $passwordConfirmation) { $errors[] = "Password does not match the password confirmation."; } if(strlen($password) < 6) { $errors[] = "Password should be at least 6 characters."; } if(!$errors) { $encodedPassword = $passwordEncoder->encodePassword($user, $password); $user->setEmail($email); $user->setPassword($encodedPassword); try { $om->persist($user); $om->flush(); return $this->json([ 'user' => $user ]); } catch(UniqueConstraintViolationException $e) { $errors[] = "The email provided already has an account!"; } catch(\Exception $e) { $errors[] = "Unable to save new user at this time."; } } return $this->json([ 'errors' => $errors ], 400); }
Just below the line where ->setPassword is called we’ll start to use Symfony’s validator instead of catching exceptions like we’re doing now. In order to use the validator, we need to use dependency injection and add a ValidatorInterface parameter to the register function. We need to import it first by adding the following line to the top of the file:
use Symfony\Component\Validator\Validator\ValidatorInterface;
Then remove the try/catch block. The ValidatorInterface has a method called validate which takes an entity as a parameter. The validate method returns a special list object of errors. So if we count the result of the validate method and it comes back as zero, that means the validation passed. Each error in the error list has a getMessage method that let’s you get the error text. Here’s the code to replace the try/catch block:
$entityErrors = $validator->validate($user); if(count($entityErrors) == 0) { // Save entity $om->persist($user); $om->flush(); return $this->json([ 'user' => $user ]); } else { foreach($entityErrors as $error) { $errors[] = $error->getMessage(); } }
There’s a few other things we need to do to get this fully validating correctly. To start, open your User entity file in src/Entity. We can add some annotations to our User entity to add validation. So for example, we can add an annotation to the $email property to say that it should be required and that it must be an email. To use these validation annotations we need to add a couple of use statements at the top of our file:
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
The first line lets us use the general validation annotations from the Symfony’s Validator service. The second let’s us specify a unique property on the entity.
There are a bunch of validation annotations we can add to our Entities. The ones we will be using are Assert\NotBlank, Assert\Email and UniqueEntity. The two that begin with assert can be placed in the annotations above the $email property. The UniqueEntity constraint has to be set a little differently. You need to add this to the User entity class as a whole, not on an individual property. That annotation needs to go right above the class under the default @ORM\Entity one. Here’s what the top part of the User entity looks like with the annotations added:
/** * @ORM\Entity(repositoryClass="App\Repository\UserRepository") * @UniqueEntity(fields={"email"}, message="This email is already in use.") */ class User implements UserInterface { /** * @ORM\Id() * @ORM\GeneratedValue() * @ORM\Column(type="integer") * @Groups("api") */ private $id; /** * @Assert\Email(message = "The email {{ value }} is not a valid email.") * @Assert\NotBlank(message = "Email cannot be empty.") * @ORM\Column(type="string", length=180, unique=true) * @Groups("api") */ private $email;
You can kind of tell from the code, but the Assert\Email line includes a message parameter that lets us configure how the error message will look when it gets triggered. The same goes for the NotBlank and UniqueEntity ones. If you’d like to take a closer look at which validation constraints you can set, take a look at the Symfony docs here.
Now open Postman and make a request to the /register route and try to add a user that’s already in your database. You should get a similar error to what you got when using the try/catch block. You can also try with a email that isn’t an email or an empty email. In both cases our app should gracefully handle the error.
Step 2: Creating our own Validator Service
We’ve got the validator working! But we can make it a little nicer to use. Say you need to write another controller method that requires validation. You would need to create the same loop to go over the errors and put them in an array so they can be output nicely. We don’t want to have to write that loop over and over again. To solve that we’ll create our own little validation service that uses Symfony’s validation service and we’ll write our own code to get the errors in a nice way.
Since Services are just regular classes, there’s no special command we can use in the terminal to create our class so we’ll have to create it manually. Under the src folder, add another folder called Service. In the Service folder create a class called ValidationService.php. Setup a class as you normally would in php. Add the namespace App\Service to the top, and create an empty class. Your file should end up looking like this:
<?php namespace App\Service; class ValidationService { }
We need to use dependency injection to get Symfony’s validation service in our own service, so create a constructor with the ValidatorInterface as one of the parameters. Then also create 2 private properties on the class. One $validator, which will hold the ValidatorInterface and one called $errors which will hold an array of errors. In the constructor set the $validator property equal to the ValidatorInterface parameter. Your class should now look something like this:
<?php namespace App\Service; use Symfony\Component\Validator\Validator\ValidatorInterface; class ValidationService { private $validator; private $errors = []; public function __construct(ValidatorInterface $validator) { $this->validator = $validator; } }
Next we need to make the method that validates our entity. So create an empty method named validate that takes a parameter called $entity. Then you can copy this validation code that’s in our controller:
$entityErrors = $validator->validate($user); if(count($entityErrors) == 0) { // Save entity $om->persist($user); $om->flush(); return $this->json([ 'user' => $user ]); } else { foreach($entityErrors as $error) { $errors[] = $error->getMessage(); } }
And paste it into your validate method. Remove the part that saves the User entity and change the line that returns the json to just return true. We also need to swap out $user in the first line of the method to be $entity and change $validator to $this->validator.
At the beginning of this method we also need to reset our $errors array, let’s set that to an empty array. And then instead of having $errors[] in the foreach loop, change it to $this->errors[]. After the foreach loop let’s return false.
Lastly, we need to create a method that gets us the errors array. So create a simple getter method to get the $errors property. Here’s what your class should look like:
<?php namespace App\Service; use Symfony\Component\Validator\Validator\ValidatorInterface; class ValidationService { private $validator; private $errors = []; public function __construct(ValidatorInterface $validator) { $this->validator = $validator; } public function validate($entity) { $this->errors = []; $entityErrors = $this->validator->validate($entity); if(count($entityErrors) == 0) { return true; } else { foreach($entityErrors as $error) { $this->errors[] = $error->getMessage(); } } return false; } public function getErrors() { return $this->errors; } }
Our ValidationService class is complete! We’re checking in the validate method if there are any errors, if there are none, return true. If there are errors, loop over the errors, add them to the errors property and then return false. And we’ll use the getErrors method to get the errors if there are any.
Let’s now use it in the UserController. Change the ValidatorInterface parameter to be our ValidationService instead. Be sure to import it at the top with a use statement:
use App\Service\ValidationService;
Then change $entityErrors to $isValid. Remove the ! in the if statement, so it saves the entity if $isValid is true. Then remove the foreach loop and change it to use the getErrors method on the $validator to set the $errors variable. If you can, try to make those changes yourself and then check it against my code below:
public function register(ObjectManager $om, UserPasswordEncoderInterface $passwordEncoder, Request $request, ValidationService $validator) { ... lines 28 - 43 ... if(!$errors) { $encodedPassword = $passwordEncoder->encodePassword($user, $password); $user->setEmail($email); $user->setPassword($encodedPassword); $isValid = $validator->validate($user); if($isValid) { // Save entity $om->persist($user); $om->flush(); return $this->json([ 'user' => $user ]); } else { $errors = $validator->getErrors(); } } return $this->json([ 'errors' => $errors ], 400); }
If you try your /register route out again it should still be working the same. Great! We’ve condensed our validation into using just this validate method rather than using the try/catch block. We can now use that any time we need to validate an entity in the future! There’s still some validation like the password checks we have that can’t easily be done with annotations though. But we’ll talk about a different way of validating this part of our entity further on in the tutorial.
Step 3: Creating an Entity Service
Saving your entity in the controller the way we’re doing it now is perfectly fine. However, we can make this code a little less verbose by creating an entity service that lets us create and save entities. Something that can not only save entities, but also validates it for us. That way our controllers will not only be smaller, we’ll be able to reuse the code with other entities that we create.
To start let’s create a folder in our Service folder called Entity. We want to keep this a little separate from our other services since we will have a new service class for each entity.
Create a file in src/Service/Entity called UserService.php and set up an empty class like below:
<?php namespace App\Service\Entity; class UserService { }
Now before we add any methods, let’s think about what we want this to do. We want it to validate, so we will need our validation service. We want it to save entities, so it will need the ObjectManager that we’re using in the register controller method. We also need it to encode passwords so we’ll need the UserPasswordEncoderInterface that’s also being used in the register method. So let’s start with a constructor with those 3 classes typed hinted as parameters. Also add private properties to the class for each of these parameters so we can access them later on and assign them in the constructor. Lastly, add a private property to store any errors. Try to write that code out yourself if you can, then compare it with my code:
<?php namespace App\Service\Entity; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use App\Service\ValidationService; class UserService { private $validator; private $om; private $passwordEncoder; private $errors = []; public function __construct(ValidationService $validator, ObjectManager $om, UserPasswordEncoderInterface $passwordEncoder) { $this->validator = $validator; $this->om = $om; $this->passwordEncoder = $passwordEncoder; } }
Next, we’ll want a function that we can use to create a new user. In there we can add the extra password confirmation validation that we have in our controller. So let’s make a method called create, that takes a single array as a parameter. We’ll use this array to initialize any values onto our User entity. Let’s assume that the array keys will be email, password and password_confirmation. We can copy the password checks from our controller and paste it into this create method. Then, if we create the same $email, $password and $passwordConfirmation variables from the $properties parameter we won’t have to make any tweaks to the code we copied. Here’s what our create method should look like now:
public function create($properties = []) { $email = isset($properties['email']) ? $properties['email'] : ""; $password = isset($properties['password']) ? $properties['password'] : ""; $passwordConfirmation = isset($properties['password_confirmation']) ? $properties['password_confirmation'] : ""; $errors = []; if($password != $passwordConfirmation) { $errors[] = "Password does not match the password confirmation."; } if(strlen($password) < 6) { $errors[] = "Password should be at least 6 characters."; } }
Next we need to create the user. We can actually copy over the same code in our controller to this create method as well! So copy the if(!$errors) block and paste it in below the password checks. Instead of returning json, lets just return true when the entity gets saved, and return false when it fails. Change the $passwordEncoder, $validator and $om to have a $this-> in front. We also need to initialize a User entity. You can do that after the initial password error checks. Just be sure to import the class at the top of your file with a use statement:
use App\Entity\User;
Here’s what your create method should look like:
public function create($properties = []) { $email = isset($properties['email']) ? $properties['email'] : ""; $password = isset($properties['password']) ? $properties['password'] : ""; $passwordConfirmation = isset($properties['password_confirmation']) ? $properties['password_confirmation'] : ""; $errors = []; if($password != $passwordConfirmation) { $errors[] = "Password does not match the password confirmation."; } if(strlen($password) < 6) { $errors[] = "Password should be at least 6 characters."; } if(!$errors) { $user = new User(); $encodedPassword = $this->passwordEncoder->encodePassword($user, $password); $user->setEmail($email); $user->setPassword($encodedPassword); $isValid = $this->validator->validate($user); if($isValid) { // Save entity $this->om->persist($user); $this->om->flush(); return true; } else { $errors = $this->validator->getErrors(); } } return false; }
We have a create method! But we have no way of getting the User that gets created. So let’s create another private property called $user. We’ll set $this->user equal to the user that gets created in our method. Then create a getter method to get the user. We also need to set the $errors array that’s created to $this->errors. Then also create a getter method for that property as well.
If you can, try to make those adjustments yourself and take a look at my code if you need help or are finished:
<?php namespace App\Service\Entity; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use App\Service\ValidationService; use App\Entity\User; class UserService { private $validator; private $om; private $passwordEncoder; private $errors = []; private $user; public function __construct(ValidationService $validator, ObjectManager $om, UserPasswordEncoderInterface $passwordEncoder) { $this->validator = $validator; $this->om = $om; $this->passwordEncoder = $passwordEncoder; } public function create($properties = []) { $email = isset($properties['email']) ? $properties['email'] : ""; $password = isset($properties['password']) ? $properties['password'] : ""; $passwordConfirmation = isset($properties['password_confirmation']) ? $properties['password_confirmation'] : ""; $errors = []; if($password != $passwordConfirmation) { $errors[] = "Password does not match the password confirmation."; } if(strlen($password) < 6) { $errors[] = "Password should be at least 6 characters."; } if(!$errors) { $user = new User(); $encodedPassword = $this->passwordEncoder->encodePassword($user, $password); $user->setEmail($email); $user->setPassword($encodedPassword); $isValid = $this->validator->validate($user); if($isValid) { // Save entity $this->om->persist($user); $this->om->flush(); $this->user = $user; return true; } else { $errors = $this->validator->getErrors(); } } $this->errors = $errors; return false; } public function getUser() { return $this->user; } public function getErrors() { return $this->errors; } }
We can now use this class for creating users instead of putting all the creation code in the controller! That means if there’s any other part of our app that we need to create a user, we have a repeatable way of doing it.
Go into your register function in the UserController. You can pretty much empty out this method. Remove all of the parameters except for the Request one. Then add a new parameter that type hints our UserService. The Request object has a way of getting all POST variables if you do:
$request->request->all();
We can pass this to the create method of our UserService and have everything handled! Try to write the new register function using the new UserService we made. When you’re done, take a look at my code:
public function register(Request $request, UserService $userService) { if($userService->create($request->request->all())) { return $this->json([ 'user' => $userService->getUser() ]); } return $this->json([ 'errors' => $userService->getErrors() ], 400); }
A lot simpler looking than what we had originally right? If we follow this same methodology with all of our other entities, our controllers will be a piece of cake to create going forward.
Step 4: JWT Service
Up next is creating a JWT service. We’ll be using it mainly to handle refreshing our JWT cookie without repeating too much code. So the first thing to do is create the class. In your src/Service folder create a new file called JwtService.php and set up an empty class.
Our service will need to be able to create JWT cookies for us and verify that a given JWT cookie is valid. We’ll start with the create function. In order to create a JWT we need a payload array, so our create function will need that as a parameter. We should ensure that there is an ‘exp’ key in the payload provided and if there isn’t one, we will create a default one. To make our lives a little easier we can copy the code we added to our onAuthenticationSuccess method in the LoginAuthenticator and tweak that a little for new JWT service.
Here is our create function with just copying the code from our LoginAuthenticator:
<?php namespace App\Service; use Firebase\JWT\JWT; class JwtService { public function createJwtCookie($payload = []) { $expireTime = time() + 3600; $tokenPayload = [ 'user_id' => $token->getUser()->getId(), 'email' => $token->getUser()->getEmail(), 'exp' => $expireTime ]; $jwt = JWT::encode($tokenPayload, getenv("JWT_SECRET")); // If you are developing on a non-https server, you will need to set // the $useHttps variable to false $useHttps = true; setcookie("jwt", $jwt, $expireTime, "", "", $useHttps, true); } }
There’s some adjustments we need to make for this to work. First we should check if there is an ‘exp’ key and use that for the $expireTime variable, otherwise use the default of time() + 3600. We can delete the $tokenPayload variable and replace any references to it with just $payload. Then we need to ensure that there is an ‘exp’ key on the $payload parameter so let’s set $payload[‘exp’] equal to $expireTime. The rest of the code can remain the same. Try coding that out yourself and then take a look at my code:
public function createJwtCookie($payload = []) { $expireTime = isset($payload['exp']) ? $payload['exp'] : time() + 3600; $payload['exp'] = $expireTime; $jwt = JWT::encode($payload, getenv("JWT_SECRET")); // If you are developing on a non-https server, you will need to set // the $useHttps variable to false $useHttps = true; setcookie("jwt", $jwt, $expireTime, "", "", $useHttps, true); }
And we’re done! Now we can head back into our onAuthenticationSuccess method in LoginAuthenticator and replace all the code that creates the JWT and cookie to use our new service. First, we need to use some dependency injection to get access to the service. So add a parameter to the constructor that type hints to JwtService. Be sure to import it by adding this line to the top of your file:
use App\Service\JwtService;
Then make a private property and set it equal to the JwtService parameter. Here is what your constructor should look like:
public function __construct(UserPasswordEncoderInterface $passwordEncoder, JwtService $jwtService) { $this->passwordEncoder = $passwordEncoder; $this->jwtService = $jwtService; }
Then, we can use the JWT service to create our cookie like so:
$this->jwtService->createJwtCookie([ 'user_id' => $token->getUser()->getId(), 'email' => $token->getUser()->getEmail() ]);
Your onAuthenticationSuccess method should now look like:
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) { $this->jwtService->createJwtCookie([ 'user_id' => $token->getUser()->getId(), 'email' => $token->getUser()->getEmail() ]); return new JsonResponse([ 'result' => true ]); }
We don’t need to pass in an expire time because we’re using the default so we are only passing the user ID and email.
Open up Postman and make sure that your code is still working by trying to login and then checking the /profile endpoint.
Next let’s create a function to verify that the token is valid. We already have that code written out in the getCredentials method of our JwtAuthenticator.php file, so let’s copy that over to a new verifyToken function in our JWT service. It will take the token as a parameter.
Be sure to import the 2 JWT specific exception classes:
use Firebase\JWT\ExpiredException; use Firebase\JWT\SignatureInvalidException;
Here’s what your verifyToken function should look like after just copying the code from our authenticator:
public function verifyToken($token) { // Default error message $error = "Unable to validate session."; try { $decodedJwt = JWT::decode($cookie, getenv("JWT_SECRET"), ['HS256']); return [ 'user_id' => $decodedJwt->user_id, 'email' => $decodedJwt->email ]; } catch(ExpiredException $e) { $error = "Session has expired."; } catch(SignatureInvalidException $e) { // In this case, you may also want to send an email to yourself with the JWT // If someone uses a JWT with an invalid signature, it could // be a hacking attempt. $error = "Attempting access invalid session."; } catch(\Exception $e) { // Use the default error message } }
There’s a couple of things we need to do to get this working. First in the line that decodes the JWT we need to change the $cookie variable to $token. Then at the bottom of the class we should return false since if it makes it past the try block, that means an error occurred. Let’s also create a private $error property onto the class and change the spots that reference $error in our function to $this->error. Then create a simple getter method to get the error. Let’s also empty out the error property if the token gets decoded successfully.
If you can, try to make those adjustments on your own. When your done, your code should look something like this:
<?php namespace App\Service; use Firebase\JWT\JWT; use Firebase\JWT\ExpiredException; use Firebase\JWT\SignatureInvalidException; class JwtService { private $error; ... lines 13 to 26 (the createJwtCookie function) ... public function verifyToken($token) { // Default error message $this->error = "Unable to validate session."; try { $decodedJwt = JWT::decode($token, getenv("JWT_SECRET"), ['HS256']); $this->error = ""; return [ 'user_id' => $decodedJwt->user_id, 'email' => $decodedJwt->email ]; } catch(ExpiredException $e) { $this->error = "Session has expired."; } catch(SignatureInvalidException $e) { // In this case, you may also want to send an email to yourself with the JWT // If someone uses a JWT with an invalid signature, it could // be a hacking attempt. $this->error = "Attempting access invalid session."; } catch(\Exception $e) { // Use the default error message } return false; } public function getError() { return $this->error; } }
Now let’s use this function to verify our token in the authenticator. We can remove the code that we copied and replace it to use the JWT service. We first need to set up a constructor and dependency inject the service into this authenticator. Do it just as you did in the LoginAuthenticator. Import it with this line at the top:
use App\Service\JwtService;
And your constructor and private property should look like this:
private $jwtService; public function __construct(JwtService $jwtService) { $this->jwtService = $jwtService; }
Then in our getCredentials method we need to call verifyToken and if that result isn’t false, return it. Otherwise get the error and throw the exception. Try to write that code out yourself. It should look something like this:
public function getCredentials(Request $request) { $cookie = $request->cookies->get("jwt"); if($result = $this->jwtService->verifyToken($cookie)) { return $result; } else { $error = $this->jwtService->getError(); } throw new CustomUserMessageAuthenticationException($error); }
Check to make sure everything is still working by going to the /profile endpoint in Postman. Once you see everything is working correctly, we need to do one more thing. Currently our user is logged in for an hour and when that hour is up, they will be logged out. Now imagine you are logged into a website and are navigating around and are abruptly logged out. It’s not the best user experience. We need to refresh the token. So, in the JWT service we’re going to perform a check after the token gets decoded. If the token is due to expire within, say 10 minutes (this can be any amount of time), refresh the token and cookie. Remember, the $decodedJwt also has an exp property that was set in the payload when we created the token. So we can compare that with the current time to see when it’s going to expire.
As always, try coding that out yourself and then compare with my code below:
public function verifyToken($token) { // Default error message $this->error = "Unable to validate session."; try { $decodedJwt = JWT::decode($token, getenv("JWT_SECRET"), ['HS256']); $this->error = ""; // Refresh token if it's expiring in 10 minutes if(time() - $decodedJwt->exp < 600) { $this->createJwtCookie([ 'user_id' => $decodedJwt->user_id, 'email' => $decodedJwt->email ]); } return [ 'user_id' => $decodedJwt->user_id, 'email' => $decodedJwt->email ]; } catch(ExpiredException $e) { $this->error = "Session has expired."; } catch(SignatureInvalidException $e) { // In this case, you may also want to send an email to yourself with the JWT // If someone uses a JWT with an invalid signature, it could // be a hacking attempt. $this->error = "Attempting access invalid session."; } catch(\Exception $e) { // Use the default error message } return false; }
Do one more check to see that your /profile endpoint is still working and then we’re done! Our user is safe from being abruptly logged out.
Conclusion
Hopefully these past few tutorials have helped you with getting your feet wet with Symfony 4 and creating an API with authentication. If you want more practice, in the next set of tutorials we are going to be using this authentication system we’ve built and create a web based resume builder. That way, you can use it to show off to a potential employer! You can say in the interview: “Here’s my resume… that was made using software I created!” :D.
Here is a link to the code we built in this part: Part 3 Code. As always if you have any questions feel free to leave a comment and if you want to be notified to future tutorials sign up for the newsletter below!