Creating a Web App with Symfony 4: Setting up Routes

This is a continuation from the previous post on Entity Services. If you haven’t read it yet, click here!

We’re in the final stretch of our backend API! We have our entities and services created, now let’s work on creating the controllers for our app.

Step 1: Creating an Address Endpoint

As we did with the BaseEntityService, we can make a base controller that can work with most of our entities. We’ll create a base controller that has methods to do the basic CRUD (create, read, update, delete) operations. Then extend that base controller for each entity controller. Before we create a base controller class however, let’s go over creating one specifically for the Address entity. That way we can see how we want to set up our controller and then go back and make a more general one that we can use for all of them.

To start, let’s create a new folder in our src/Controller folder called Auth. I’ve named it Auth because each of the controllers in this folder are meant to be used after the user is already authenticated. This isn’t a requirement by Symfony or anything, it’s just a way to keep things organized.

Then in your src/Controller/Auth folder create a new file called AddressController.php and set up an empty class. It should extend Symfony’s AbstractController. Here’s what your code should look like:

<?php
namespace App\Controller\Auth;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;


class AddressController extends AbstractController
{

}

The first method we’ll make is a way to create new addresses. Let’s call the method createAddress. It will take in POST data and create a new Address for the logged in user. So, we need a Request object to get any posted data and we need an AddressService class to create the address. In order to use those classes we’ll need to import them at the top of our file like so:

use App\Service\Entity\AddressService;
use Symfony\Component\HttpFoundation\Request;

If you remember from the register function we made in the UserController, you can get all the posted data from the Request object like so:

$request->request->all();

We can assign the posted data to a variable and append the user to it using the special controller method getUser(), which get’s the logged in user. Then run the posted data through the create and save method of our AddressService. Here’s what that looks like:

public function createAddress(Request $request, AddressService $addressService)
{
   $postData = $request->request->all();
   $postData['user'] = $this->getUser();

   $addressService->create($postData)->save();

}

If the save is successful, we should output the entity as JSON. If it’s unsuccessful, we should output the errors with a 400 response code. Try to update the code on your own and then take a look at my code below:

public function createAddress(Request $request, AddressService $addressService)
{
   $postData = $request->request->all();
   $postData['user'] = $this->getUser();

   if($addressService->create($postData)->save())
   {
	   return $this->json([
		   'address' => $addressService->getEntity()
	   ]);
   }
   else
   {
	   return $this->json([
		   'errors' => $addressService->getErrors()
	   ], 400);
   }
}

The final thing we haven’t done yet is create the route for this controller. We’ll make the path /address, request method POST and name the route api_address_create. If you need help setting this up, take a look at one of the route annotations in the UserController. When you’re done, take a look at the completed controller function with the route annotation:

/**
 * @Route("/address", name="api_address_create", methods={"POST"})
 */
public function createAddress(Request $request, AddressService $addressService)
{
   $postData = $request->request->all();
   $postData['user'] = $this->getUser();

   if($addressService->create($postData)->save())
   {
	   return $this->json([
		   'address' => $addressService->getEntity()
	   ]);
   }
   else
   {
	   return $this->json([
		   'errors' => $addressService->getErrors()
	   ], 400);
   }
}

Also, to use the Route annotation we need this use statement at the top of our file:

use Symfony\Component\Routing\Annotation\Route;

One last thing we need to do is make sure that there is a logged in user. To do that we can also use an annotation. If you remember from the profile method in the UserController, we used @IsGranted(“ROLE_USER”) as an annotation to prevent someone from accessing that route without being logged in. We can do this here as well, but since every method in this class is going to require a user to be logged in, we can add this annotation on the entire class. So, just above the class definition, add the annotation @IsGranted(“ROLE_USER”). Here is what that looks like:

/**
 * @IsGranted("ROLE_USER")
 */
class AddressController extends AbstractController
{

You will also need to add this use statement to the top of your class file:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

Here is what your class should look like:

<?php
namespace App\Controller\Auth;

use App\Service\Entity\AddressService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

/**
 * @IsGranted("ROLE_USER")
 */
class AddressController extends AbstractController
{
   /**
    * @Route("/address", name="api_address_create", methods={"POST"})
    */
   public function createAddress(Request $request, AddressService $addressService)
   {
       $postData = $request->request->all();
       $postData['user'] = $this->getUser();

       if($addressService->create($postData)->save())
       {
           return $this->json([
               'address' => $addressService->getEntity()
           ]);
       }
       else
       {
           return $this->json([
               'errors' => $addressService->getErrors()
           ], 400);
       }
   }
}

Let’s test it out! Open Postman and make sure you’re logged in to your app. Open a new tab and make a POST requested pointed to /address like so:

And hit send. If you hit the preview tab in the results area, you should see a NotNullConstraintViolationException error. That’s because we sent an empty request. But our app should be able to handle that. So let’s open our Address entity and add some validation annotations like we have with our User entity.

Step 2: Address Validation

At the top of our Address entity file we need to add this use statement so we can use validation constraints:

use Symfony\Component\Validator\Constraints as Assert;

Let’s make each of the address properties required or not blank except for line2. To do that we need to add an Assert\NotBlank annotation above line1, city, state and postal_code. For line2, we are still going to get an error if it’s not passed in. So for line2 let’s set a default value of an empty string. To do that, simple set $line2 equal to “”. If you can, try to write that code on your own. If you need help with the NotBlank annotation, take a look at how it’s done in the User entity. Here is what your properties should look like in the Address entity with those changes:

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "Address line 1 may not be blank.")
 */
private $line1;

/**
 * @ORM\Column(type="string", length=255)
 */
private $line2 = "";

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "City may not be blank.")
 */
private $city;

/**
 * @ORM\Column(type="string", length=2)
 * @Assert\NotBlank(message = "State may not be blank.")
 */
private $state;

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message = "Postal code may not be blank.")
 */
private $postal_code;

There’s also another validation constraint we should use which is Assert\Length. Here’s a basic example of the Assert\Length constraint:

/*
 * @Assert\Length(min = 2, max = 20, minMessage = "Property should be at least {{ limit }} characters long.", maxMessage = "Property cannot exceed {{ limit }} characters.", exactMessage = "Property must be {{ limit }} characters.")
 */

The min and max options are required, while the message options are optional, though you generally would want to enter your own message. The exactMessage is only used in cases where min and max are the same, while minMessage and maxMessage are used when min and max are different. The {{ limit }} variable in the messages refer to the min or max you set. So for example in the minMessage {{ limit }} would show 2 and in the maxMessage {{ limit }} would show 20. For more information on this constraint take a look at the Symfony Docs here.

With that, add an Assert\Length constraint for each of the properties. You can set a 0 min for most of the properties since NotBlank checks to make sure they aren’t empty. I would say the only ones that we should set a non-zero value for min would be the postal_code and state since those are set lengths. One additional thing to note is that Assert\Length will not check for null values so we need to use both NotBlank and the Length validation constraints. We can’t just use the min parameter of Assert\Length to make sure a value is provided.

Here is the code after adding the Assert\Length constraints:

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "Address line 1 may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage = "Address line 1 cannot exceed {{ limit }} characters.")
 */
private $line1;

/**
 * @ORM\Column(type="string", length=255) 
 * @Assert\Length(min = 0, max = 255, maxMessage = "Address line 2 cannot exceed {{ limit }} characters.")
 */
private $line2 = "";

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "City may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage = "City cannot exceed {{ limit }} characters.")
 */
private $city;

/**
 * @ORM\Column(type="string", length=2)
 * @Assert\NotBlank(message = "State may not be blank.")
 * @Assert\Length(min = 2, max = 2, exactMessage = "State is required to be {{ limit }} characters.")
 */
private $state;

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message = "Postal code may not be blank.")
 * @Assert\Length(min = 5, max = 10, minMessage = "Postal code must be at least {{ limit }} characters.", maxMessage = "Postal code cannot exceed {{ limit }} characters.")
 */
private $postal_code;

With those constraints set, let’s try sending the same POST request again. You should get something like this as a result:

Great! It’s handling errors gracefully. So now let’s see if we can post some actual data. Add some fake data for line1, line2, city, state, postalCode and then hit send!

Here’s what you should get:

Now if you notice it’s showing the entire user object with the encoded password. We prevented this in the /profile UserController route by adding groups and then specifying the group in the $this->json method.

Let’s go back to our Address entity and add the api group like we did with the User entity. You’ll need to add this use statement at the top of your Address class file:

use Symfony\Component\Serializer\Annotation\Groups;

Then for each property we want to include in the JSON results we would add a @Groups(“api”) annotation.

SIDE NOTE:
It’s been a little bit since we talked about groups so just in case you don’t remember, the group “api” isn’t some special thing associated with Symfony. That can be called anything you want.

Here’s the Address entity properties with the group annotation added:

/**
 * @ORM\Id()
 * @ORM\GeneratedValue()
 * @ORM\Column(type="integer")
 * @Groups("api")
 */
private $id;

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "Address line 1 may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage = "Address line 1 cannot exceed {{ limit }} characters.")
 * @Groups("api")
 */
private $line1;

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\Length(min = 0, max = 255, maxMessage = "Address line 2 cannot exceed {{ limit }} characters.")
 * @Groups("api")
 */
private $line2 = "";

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message = "City may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage = "City cannot exceed {{ limit }} characters.")
 * @Groups("api")
 */
private $city;

/**
 * @ORM\Column(type="string", length=2)
 * @Assert\NotBlank(message = "State may not be blank.")
 * @Assert\Length(min = 2, max = 2, exactMessage = "State is required to be {{ limit }} characters.")
 * @Groups("api")
 */
private $state;

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message = "Postal code may not be blank.")
 * @Assert\Length(min = 5, max = 10, minMessage = "Postal code must be at least {{ limit }} characters.", maxMessage = "Postal code cannot exceed {{ limit }} characters.")
 * @Groups("api")
 */
private $postal_code;

/**
 * @ORM\OneToOne(targetEntity="App\Entity\User", inversedBy="address", cascade={"persist", "remove"})
 * @ORM\JoinColumn(nullable=false)
 * @Groups("api")
 */
private $user;

I’ve added each property to the api group. Next we need to update our controller method to use this group. The second parameter in the json method is the status code to return. For that we’ll set it to 200. The third parameter is a headers array. We can set this to an empty array. The fourth parameter is where we set the groups. The fourth parameter should be an array. Set a groups key that points to an array of groups which just contains our api group. Try doing that on your own. Look at the UserController profile method if you are unsure of how it’s done.

Here’s what your updated method should look like now:

/**
 * @Route("/address", name="api_address_create", methods={"POST"})
 */
public function createAddress(Request $request, AddressService $addressService)
{
   $postData = $request->request->all();
   $postData['user'] = $this->getUser();

   if($addressService->create($postData)->save())
   {
	   return $this->json([
		   'address' => $addressService->getEntity()
	   ], 200, [], [
		   'groups' => ['api']
	   ]);
   }
   else
   {
	   return $this->json([
		   'errors' => $addressService->getErrors()
	   ], 400);
   }
}

Now if you try adding another address, you’re going to get a new error: UniqueConstraintViolationException. That’s because we set our relationship between the user and address to be one to one. So if we go to make another address with a user that already has one, it’s going to throw an exception. To handle this we need to add another validation constraint. It’s one we’ve used before, the UniqueEntity constraint. If you open the User entity, you’ll see it’s set on the class itself as opposed to a specific property.

So let’s take that UniqueEntity annotation and add it to the Address entity. We’ll need to import the class at the top of our file:

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

Then add the annotation to the class like so:

/**
 * @ORM\Entity(repositoryClass="App\Repository\AddressRepository")
 * @UniqueEntity(fields={"user"}, message="This user already has an address.")
 * @Gedmo\SoftDeleteable()
 */
class Address
{

Here we’re saying that the user field should be unique and if someone tries to add an address to a user that already has one, the message “This user already has an address” will show.

So one more time, send the same request in Postman. The error should be gracefully caught. If you want to test out that creating an address works correctly, you’ll need to manually delete the address record from your database.

After deleting the record and posting to /address again you should get something like this:

Step 3: List Address Endpoint

Next we’ll work on getting the address. So let’s make a new method in our AddressController called getAddress. Since a user will only ever have 1 address, we don’t need to add any complicated searching for addresses. We can just use an AddressRepository to query the database for the address that belongs to the logged in user. So, the only parameter that our getAddress method needs is an AddressRepository. Then to find a single entity using the respository classes you would call a method called findOneBy. It takes in a search parameter that’s an array of search terms with the key being the property and the value being the value we’re searching by.

Here’s how you would query for an address for a specific user:

$address = $addressRepository->findOneBy(['user' => $this->getUser()]);

The user can be either a full User entity or a user id. Then we would return the address using the $this->json method in the controller just as we did after we created an address. For the actual route information, let’s make the endpoint /address, except this one only takes GET requests and name it api_address_list. If you can, try to code that out on your own and then take a look at my code:

/**
 * @Route("/address", name="api_address_list", methods={"GET"})
 */
public function getAddress(AddressRepository $addressRepository)
{
   $address = $addressRepository->findOneBy(['user' => $this->getUser()]);

   return $this->json([
	   'address' => $address
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

Step 4: Updating an Existing Address

Updating an address is going to be similar to the createAddress method, except this time we need to first check that the address exists and if it does, make sure that it belongs to the logged in user. Once that checks out, we can update any fields that were passed to us.

With that said, let’s create a new method called updateAddress. For this we’ll need a Request object to get any values passed to us. We will need an AddressService to make any changes to our entity and we will need an AddressRepository to pull the address we are updating.

We can copy the code in the getAddress function that queries for the user’s address since it will be the same. Next, we need to check that an address was returned from that. If we find the address, call the setEntity method on our AddressService.

Now for updating, rather than sending a POST request we are sending a PATCH request. A PATCH is similar to POST with respect to getting the values that are sent to our endpoint. You would use the same code to get all the PATCHed data as you did with POST data:

$request->request->all();

In order to use the method above, we need to send the data in Postman as x-www-form-urlencoded data as opposed to form-data. So going forward that’s what we’ll use when making change type requests.

To make sure that the User does not get changed, we should do the same thing we did with creating an address. Grab the PATCHed data and add a user key with the logged in user. Then we pass the patched data to the setProperties method on our AddressService and then call save.

We should set it up similar to the create method, where if there are errors we output them with a 400 status. If it saves successfully, output the entity. Try coding that out own your own and then take a look at my code below:

public function updateAddress(Request $request, AddressService $addressService, AddressRepository $addressRepository)
{
   $address = $addressRepository->findOneBy(['user' => $this->getUser()]);

   if($address)
   {
	   $addressService->setEntity($address);

	   $patchedData = $request->request->all();
	   $patchedData['user'] = $this->getUser();

	   if($addressService->setProperties($patchedData)->save())
	   {
		   return $this->json([
			   'address' => $addressService->getEntity()
		   ], 200, [], [
			   'groups' => ['api']
		   ]);
	   }
	   else
	   {
		   return $this->json([
			   'errors' => $addressService->getErrors()
		   ], 400);
	   }
   }
}

The last few things we should do are first, if the address isn’t found, we should output an error, similar to how the error is outputted when an error occurs while saving. And lastly, we need to setup the route annotation. For the path, we’ll stick with /address. For name we will call it api_address_update and for methods only allow PATCH. Here is the completed updateAddress method:

/**
 * @Route("/address", name="api_address_update", methods={"PATCH"})
 */
public function updateAddress(Request $request, AddressService $addressService, AddressRepository $addressRepository)
{
   $address = $addressRepository->findOneBy(['user' => $this->getUser()]);

   if($address)
   {
	   $addressService->setEntity($address);

	   $patchedData = $request->request->all();
	   $patchedData['user'] = $this->getUser();

	   if($addressService->setProperties($patchedData)->save())
	   {
		   return $this->json([
			   'address' => $addressService->getEntity()
		   ], 200, [], [
			   'groups' => ['api']
		   ]);
	   }
	   else
	   {
		   return $this->json([
			   'errors' => $addressService->getErrors()
		   ], 400);
	   }
   }
   else
   {
	   return $this->json([
		   'errors' => ['Address not found for user.']
	   ], 404);
   }
}

Now there’s one slight change I made to the status code when the address isn’t found. I changed the status code to 404, which means that the item wasn’t found. Which makes sense because we are PATCHing to something that doesn’t exist yet. Also, I’m wrapping the error message in an array in order to stay consistent with the other errors.

And that’s all for the AddressController. We aren’t going to add a delete address method since you would only ever create a new or edit your existing address. If we had multiple addresses, I would include a delete method here, but since it’s a one to one relationship, I think leaving off the ability delete is fine.

Step 5: Creating a Base Controller

Now that we have a basic controller built out, let’s try to see which pieces of the AddressController we can use to make reusable methods. Before we do anything though we need to create the base controller class. Let’s call it BaseAuthController since it’s meant to only be used for logged in users. We’ll make it an abstract class too since we will never use this base controller directly. Set that class up and if you need help take a look at my code below:

<?php
namespace App\Controller\Auth;


use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

abstract class BaseAuthController extends AbstractController
{

}

If you notice I am extending the Symfony AbstractController class so that I can use methods like getUser and json.

One issue that we are going to run into is type hinting the specific service or repository for the entity we’re working with. One way around this is to create protected methods that take the generic or parent class as type hinted parameters, and then we create methods that use these protected methods and pass in the service or repository that’s needed.

So for example, let’s create a general create method based on the createAddress method. We would change the AddressService to BaseEntityService. The rest would essentially stay the same. Maybe to stay consistent for each entity, we would change the ‘address’ key in the JSON that we’re returning to ‘data’. Our generic method would look something like this:

protected function createEntity(Request $request, BaseEntityService $entityService)
{
    $postData = $request->request->all();
    $postData['user'] = $this->getUser();

    if($entityService->create($postData)->save())
    {
        return $this->json([
            'data' => $entityService->getEntity()
        ], 200, [], [
            'groups' => ['api']
        ]);
    }
    else
    {
        return $this->json([
            'errors' => $entityService->getErrors()
        ], 400);
    }
}

And then our createAddress method would change to look like this:

public function createAddress(Request $request, AddressService $addressService)
{
    return $this->createEntity($request, $addressService);
}

Just remember to update the AddressController to extend our new BaseAuthController instead of AbstractController.

Here is the full code for our BaseAuthController so far:

<?php
namespace App\Controller\Auth;


use App\Service\Entity\BaseEntityService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

abstract class BaseAuthController extends AbstractController
{
   protected function createEntity(Request $request, BaseEntityService $entityService)
   {
       $postData = $request->request->all();
       $postData['user'] = $this->getUser();

       if($entityService->create($postData)->save())
       {
           return $this->json([
               'data' => $entityService->getEntity()
           ], 200, [], [
               'groups' => ['api']
           ]);
       }
       else
       {
           return $this->json([
               'errors' => $entityService->getErrors()
           ], 400);
       }
   }
}

Now let’s test this out to make sure our AddressController still works with the change to use the base controller method. You’ll need to manually delete the existing address in our database before testing it out. When you’ve confirmed it’s working we can move on to the next part.

Step 6: Creating a Phone Controller

We’re going to pause on adding more methods to our base controller for now. With the Address entity being somewhat different than the other entities with it being a OneToOne relationship as opposed to a ManyToOne, we can’t use the getAddress and updateAddress as good examples of methods that can be converted to generic methods.

So with that, we’re going to work on creating a Phone Controller with create, list (read), update, and delete methods. Then, we’ll look at creating generic methods based on the ones in our Phone controller.

First up let’s create our PhoneController file. Set it up as you would normally, we’ll also have it extend our BaseAuthController since we can use the createEntity method for the create phone endpoint.

So, like we did in our AddressController, let’s make a createPhone method that takes a Request parameter, and in this case a PhoneService parameter. Then inside it will simply return the result of $this->createEntity and pass the two parameters to it.

One other thing we need to do is set up the route annotations. Our route will be /phone, we’ll name it api_phone_create and the only methods it should allow are POST

You should be able to write this all out on your own by now, but if you need help, look back at how we made the createAddress method or take a look at my code below:

/**
 * @Route("/phone", name="api_phone_create", methods={"POST"})
 */
public function createPhone(Request $request, PhoneService $phoneService)
{
    return $this->createEntity($request, $phoneService);
}

Also, be sure to import the Request, PhoneService and Route classes at the top of your file like so:

use App\Service\Entity\PhoneService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

Now, let’s open Postman and make sure this endpoint is working. Set the url to POST to your /phone endpoint, make sure you’re logged in and hit send. If all goes well, you most likely will hit a NotNullConstraintViolationException :D. Just as we did in our Address entity, we also need to update our Phone entity to have constraints for each of the entity properties. So, open your Phone entity class and we’ll start adding some constraints!

Step 7: Phone Validation

The first thing we’ll need to do is import the Constraints namespace at the top of our file like so:

use Symfony\Component\Validator\Constraints as Assert;

Next, let’s add a constraint to the phone and name property to be required. If you remember from the Address entity, we used the NotBlank constraint to make things required. So just above each of those properties add Assert/NotBlank(). Also include a message that will be output when it’s found that either of those two are blank. If you need a refresher, take a look at how we did it in the Address entity.

Here is what your properties should look like with the NotBlank constraint:

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message="Phone may not be blank.")
 */
private $phone;

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message="Name may not be blank.")
 */
private $name;

The other constraint we have to add is setting limits and minimums on the properties. We use the Assert\Length constaint to do that. For the phone property, it should be required that someone enters 10 and only 10 characters, so this one will be similar to the state property in our Address entity. The min parameter will be 10 and the max parameter will be 10. Then we will use the exactMessage parameter to set our error message if this constraint fails.

For the name, we can use 0 for the min parameter since NotBlank covers checking to make sure it’s not empty and for the max parameter we’ll set it to 255. And we’ll only need to set a maxMessage parameter to be used when this constraint fails. Try setting up those on your own, and then take a look at my code below:

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message="Phone may not be blank.")
 * @Assert\Length(min = 10, max = 10, exactMessage = "Phone is required to be {{ limit }} characters.")
 */
private $phone;

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message="Name may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage="Name cannot exceed {{ limit }} characters.")
 */
private $name;

One final thing we need to do is add groups to our properties. Remember we made it so only properties that are in the api group will be output when created using the createEntity method. So let’s do that now. First we need to import the Groups class like so:

use Symfony\Component\Serializer\Annotation\Groups;

Then add @Groups(“api”) to each of the entity properties. Your property definitions should look like this:

/**
 * @ORM\Id()
 * @ORM\GeneratedValue()
 * @ORM\Column(type="integer")
 * @Groups("api")
 */
private $id;

/**
 * @ORM\Column(type="string", length=10)
 * @Assert\NotBlank(message="Phone may not be blank.")
 * @Assert\Length(min = 10, max = 10, exactMessage = "Phone is required to be {{ limit }} characters.")
 * @Groups("api")
 */
private $phone;

/**
 * @ORM\Column(type="string", length=255)
 * @Assert\NotBlank(message="Name may not be blank.")
 * @Assert\Length(min = 0, max = 255, maxMessage="Name cannot exceed {{ limit }} characters.")
 * @Groups("api")
 */
private $name;

/**
 * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="phones")
 * @ORM\JoinColumn(nullable=false)
 * @Groups("api")
 */
private $user;

Now, let’s go back into Postman and hit send again. It should fail gracefully now and show us errors formatted in JSON. Great! We have validation working for our Phone entity. Now let’s make sure we can actually create a phone in our database. Under the body tab, check off x-www-form-urlencoded and add a name and phone that can be POSTed to our api endpoint.

You should get something like this returned:

Amazing! We have our createPhone endpoint created! Repeat this process one more time so we can have 2 phones in our system for our user.

Step 8: Creating a way to list out phones

Now let’s work on a way to list out each of the user’s phones. It will be pretty similar to the getAddress method. Let’s use that as a starting point and we’ll do some tweaking to make it work for the Phone entity.

Here’s our getAddress method:

/**
 * @Route("/address", name="api_address_list", methods={"GET"})
 */
public function getAddress(AddressRepository $addressRepository)
{
   $address = $addressRepository->findOneBy(['user' => $this->getUser()]);

   return $this->json([
	   'address' => $address
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

Some quick easy things we can do: change the route to be /phones, change the route name to api_phone_list, change getAddress to getPhones and change the AddressRepository to PhoneRepository along with the parameter name. Make that parameter $phoneRepository. Be sure to import the PhoneRepository at the top of your class like so:

use App\Repository\PhoneRepository;

Next replace any reference to address with phone. When your done, it should look something like this:

/**
 * @Route("/phone", name="api_phone_list", methods={"GET"})
 */
public function getPhones(PhoneRepository $phoneRepository)
{
   $phones = $phoneRepository->findOneBy(['user' => $this->getUser()]);

   return $this->json([
	   'phones' => $phones
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

This would work if our users could only have one phone. But since a user can have more than one, there’s going to be an issue with using the findOneBy method on the repository. So for that we’ll call findBy instead. That method is pretty much the same as findOneBy except it returns more than just one result.

Your updated code should now look like:

/**
 * @Route("/phone", name="api_phone_list", methods={"GET"})
 */
public function getPhones(PhoneRepository $phoneRepository)
{
   $phones = $phoneRepository->findBy(['user' => $this->getUser()]);

   return $this->json([
	   'phones' => $phones
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

Let’s see if it works in Postman. Open a new tab and make a get request to your /phones route. And bam! You should see the following:

The two phones that we added are listed out from this endpoint! Now, what if you wanted to get a specific phone? For that we can either modify this method or create a new method. I’ve decided to make a new method, simply for the fact that I wanted to keep the controller methods doing one job as opposed to multiple. But it is perfectly fine if you want to combine both getting a specific entity and multiple.

So for getting a specific phone entity, let’s create a new method called getPhone. It will need a PhoneRepository parameter so we can query the database. We will also need one other parameter. We’re going to use the phone ID to get a specific phone from our database. So add a parameter called $phoneId. It doesn’t need to be type hinted.

The rest of the function is going to be pretty similar to our getPhones method except instead of using findBy, you guessed it, we’re going to use findOneBy. We are also going to include the $phoneId in the search parameters for the findOneBy method. Try to code this method out on your own and then take a look at my code below:

public function getPhone(PhoneRepository $phoneRepository, $phoneId)
{
   $phone = $phoneRepository->findOneBy(['user' => $this->getUser(), 'id' => $phoneId]);

   return $this->json([
	   'phone' => $phone
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

One thing you may ask is why we’re keeping the ‘user’ => $this->getUser() in the search parameter for findOneBy if we’re searching using the ID of the phone. That’s to prevent someone who’s logged in as a different user having the ability to view a phone that doesn’t belong to them.

Next we need to work on the route annotation. This one will introduce a new feature that we haven’t used yet in this tutorial, slugs! Symfony let’s you use slugs to insert a piece of a url that may be customizable. For example, say you wanted to access a Phone entity with ID #1. To do that we would want our url to be /phone/1. The 1 in the url can vary. If you wanted to access Phone #2, you’d go to /phone/2 and so on. It’s pretty intuitive with route annotations. You can use the name of your parameters in a route annotation and Symfony will know to use that part of the url as the parameter’s value.

So for example, we have a $phoneId parameter in our getPhone method. We can use /phone/{phoneId} for our route and whatever someone puts after /phones/ will be used as the value for $phoneId. With that said, here’s the route annotation for our getPhone method:

/**
 * @Route("/phone/{phoneId}", name="api_phone_list_one", methods={"GET"})
 */

Let’s open another tab in Postman to make sure it works. Point your url to /phones/1 and hit send.

You should be seeing the first phone in your database!

With those 2 created, let’s work on converting them so we can reuse them later on with our other entities. We’ll start by copying those two methods into our BaseAuthController. Simliar to how we converted over our createAddress into the createEntity method, we want to make these two new methods more generic. So instead of getPhone, let’s call it getEntity and instead of getPhones, we’ll call it getEntities.

For the PhoneRepository parameter, we need to use a repository class that will work for all of our repositories. The one we need to use is called EntityRepository. That’s because we’re using two different kinds of repositories throughout our app.

TWO REPOSITORIES EXPLANATION:

The Address, Phone, User and Resume repositories all extend ServiceEntityRepository. The ResumeSection and ResumeSectionItem uses SortableRepository. Both the SortableRepository and the ServiceEntityRepository extend the EntityRepository class.

For the $phoneId parameter, let’s use $entityId instead. Next, update each of the methods, replacing any instance of phone/phones with entity/entities. For the JSON that’s returned, let’s stay consistent with how an entity is returned when we create one, so we’ll use data as the key that the entity or entities are output on. Try making those changes on your own, if you need help, take a look at my code below:

protected function getEntities(EntityRepository $entityRepository)
{
   $entities = $entityRepository->findBy(['user' => $this->getUser()]);

   return $this->json([
	   'data' => $entities
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

protected function getEntity(EntityRepository $entityRepository, $entityId)
{
   $entity = $entityRepository->findOneBy(['user' => $this->getUser(), 'id' => $entityId]);

   return $this->json([
	   'data' => $entity
   ], 200, [], [
	   'groups' => ['api']
   ]);
}

The only other change that I didn’t mention above is that I made the methods protected. That’s just because these methods aren’t meant to actually be called outside of our controller classes.

Ok, now let’s update our PhoneController to use these methods in getPhones and getPhone. It should look something like this when you’re done:

/**
 * @Route("/phone", name="api_phone_list", methods={"GET"})
 */
public function getPhones(PhoneRepository $phoneRepository)
{
   return $this->getEntities($phoneRepository);
}

/**
 * @Route("/phone/{phoneId}", name="api_phone_list_one", methods={"GET"})
 */
public function getPhone(PhoneRepository $phoneRepository, $phoneId)
{
   return $this->getEntity($phoneRepository, $phoneId);
}

Do a quick check in Postman to make sure everything is still working (it should be). Then let’s move on to updating a phone. We’re going to go straight into making a generic updateEntity method since it will be a combination of the updateAddress method in the AddressController and the original getPhone method.

Step 9: Creating a route to update a phone

We can copy the updateAddress method from the AddressController so we have a starting point. It will be pretty similar except for the way an entity is first pulled. So paste your updateAddress method into the BaseAuthController and rename it to updateEntity.

Next let’s work on generalizing this method. Change any instance of Address to Entity. So AddressService will change to our BaseEntityService. The AddressRepository will change to EntityRepository. Then update the method to use any of your newly named parameters. When your done it should look something like this:

protected function updateEntity(Request $request, BaseEntityService $entityService, EntityRepository $entityRepository)
{
   $entity = $entityRepository->findOneBy(['user' => $this->getUser()]);

   if($entity)
   {
	   $entityService->setEntity($entity);

	   $patchedData = $request->request->all();
	   $patchedData['user'] = $this->getUser();

	   if($entityService->setProperties($patchedData)->save())
	   {
		   return $this->json([
			   'data' => $entityService->getEntity()
		   ], 200, [], [
			   'groups' => ['api']
		   ]);
	   }
	   else
	   {
		   return $this->json([
			   'errors' => $entityService->getErrors()
		   ], 400);
	   }
   }
   else
   {
	   return $this->json([
		   'errors' => ['Entity not found for user.']
	   ], 404);
   }
}

The main thing we need to add is a way to update a specific entity. To do that we’re going to do something similar to how we’re pulling a single entity in the getPhone method. Let’s add an $entityId parameter, and add an id search parameter in our findOneBy method call like so:

$entity = $entityRepository->findOneBy(['user' => $this->getUser(), 'id' => $entityId]);

That should be all we need to do. It will update the entity just like it does in the updateAddress entity. Any parameters that are posted will get updated based on the $entityId that’s passed. Great! Let’s add this to our PhoneController.

In the PhoneController create a method called updatePhone with the same parameters as the updateEntity method we just made. Change the BaseEntityService to PhoneService, change EntityRepository to PhoneRepository and $entityId to $phoneId.

Our route annotation will look a little like the getPhone route annotation. The route will be /phone/{phoneId}, we’ll make the name api_phone_update and the methods should be PATCH.

Then all we need to do is call $this->updateEntity with the parameters passed in and our update route should be good to go. Here’s what it looks like:

/**
 * @Route("/phone/{phoneId}", name="api_phone_update", methods={"PATCH"})
 */
public function updatePhone(Request $request, PhoneService $phoneService, PhoneRepository $phoneRepository, $phoneId)
{
   return $this->updateEntity($request, $phoneService, $phoneRepository, $phoneId);
}

Let’s check to make sure it works in Postman. Open a new tab and point it to /phone/1. Set the method to PATCH. Then under the Body tab check off x-www-form-urlencoded and set it up to change the name of the phone.

Then hit send! If all goes well, you should see phone #1 returned back in JSON with the name updated to whatever you chose! You can also test out changing the phone number as well.

Step 10: Deleting Phones

The final action we need to make is a way to delete phones. For this we need to add some extra functionality to our BaseEntityService. So let’s start by opening that class up. At the bottom let’s add another method called deleteEntity. Pretty much what this will do is delete the entity that’s being managed by our service (the one that we get when calling the getEntity method).

The first thing we need to do is make sure that there is an entity to delete. If there is no entity, we should add an error to our errors array. If there is an entity, we need to delete it. In order to delete an entity with Doctrine you call the remove method on an ObjectManager. The remove method takes the entity your deleting as a parameter. Then, like we did in our save method, we call flush to actually have the query be ran. After deleting let’s return true and return false if there is no entity to delete.

Here’s what your code should look like:

public function deleteEntity()
{
   if($this->getEntity())
   {
	   $this->om->remove($this->getEntity());
	   $this->om->flush();
	   return true;
   }
   else
   {
	   $this->errors[] = "You must first set an entity before deleting it!";
   }

   return false;
}

One little extra thing we can add to this method is giving it an optional parameter. That way we don’t need to call the setEntity method before we want to delete an entity. If someone passes an entity, set it here. If you can, try to make that little change on your own and then take a look at the updated code below:

public function deleteEntity($entity = false)
{
   if($entity)
   {
	   $this->setEntity($entity);
   }


   if($this->getEntity())
   {
	   $this->om->remove($this->getEntity());
	   $this->om->flush();
	   return true;
   }
   else
   {
	   $this->errors[] = "You must first set an entity before deleting it!";
   }

   return false;
}

Next let’s get back to our controllers. We’re going to live life on the edge and go straight into making a generic controller function for deleting an entity .

In order to delete, we’ll need pretty much the same things as we needed when we update. The only thing we won’t need is the Request object. That’s because we don’t need to read any posted data. We can delete based on the ID that’s passed through the url.

So to start, let’s create a protected deleteEntity method in our BaseAuthController with the following parameters: BaseEntityService, EntityRepository and an entity ID.

Next, like we did in the updateEntity method we need to query for the entity we’re trying to delete. We want to make sure the entity actually exists before deleting it. If nothing is returned from the query, return an error in JSON. If there is an entity, we’ll call our deleteEntity method and pass in the entity we found.

We technically should always have an entity at this point so we don’t really need to check if calling deleteEntity returns true or not, but just to be safe let’s wrap the result of that method call in an if statement and return any errors in JSON. If deleting the entity returns true, then let’s just return an array of [‘result’ => true] in JSON.

As always, try writing it on your own first but if you need help feel free to check my code below:

protected function deleteEntity(BaseEntityService $entityService, EntityRepository $entityRepository, $entityId)
{
   $entity = $entityRepository->findOneBy(['user' => $this->getUser(), 'id' => $entityId]);

   if($entity)
   {
	   if($entityService->deleteEntity($entity))
	   {
		   return $this->json([
			   'result' => true
		   ], 200);
	   }
	   else
	   {
		   return $this->json([
			   'errors' => $entityService->getErrors()
		   ], 400);
	   }
   }
   else
   {
	   return $this->json([
		   'errors' => ['Entity not found for user.']
	   ], 404);
   }
}

Now let’s go into our PhoneController and setup a delete endpoint! Create a method called deletePhone with the same parameters as the deleteEntity method in our BaseAuthController. Change the parameters to use the phone version of the service, repository and ID. Then inside we’ll call $this->deleteEntity and pass in the parameters.

Here’s what your method should look like:

public function deletePhone(PhoneService $phoneService, PhoneRepository $phoneRepository, $phoneId)
{
   return $this->deleteEntity($phoneService, $phoneRepository, $phoneId);
}

For the route annotation, we’re going to copy the updatePhone one. Leave the route the same, change the name to api_phone_delete and change the methods to be DELETE.

Here’s the annotation code:

/**
 * @Route("/phone/{phoneId}", name="api_phone_delete", methods={"DELETE"})
 */

Let’s see if it’s working in Postman! Create a new tab and set your url to your /phone/1 route. Set the method to DELETE and hit send. You should get something like this:

Now, leave everything the same and hit send again. You should see an error saying “Entity not found for user.”. That’s the error we set in our controller method! Great, so our delete method is actually working. If you want to check in either phpMyAdmin or MySQL workbench, the records are not actually being deleted. Since we set up soft deletes for our entities, the deleted_at column is simply being updated with the date the entity was deleted. Through the magic of Doctrine and the package we’re using for soft deletes (StofDoctrineExtensionsBundle), it knows that anything that has a time stamp in the deleted_at column is considered deleted! You can also check in the /phone GET route that only the second phone is being returned.

The last thing we’ll need to do on our PhoneController is set it so you must be logged in in order to access any of the endpoints. We do this the same way we did in the AddressController. Add this annotation just above the class definition:

/**
 * @IsGranted("ROLE_USER")
 */
class PhoneController extends BaseAuthController
{

You’ll also need to import the IsGranted class by adding this line to the top of your file:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

With that, we’re done with our PhoneController!

Step 11: Finishing up our controllers

The remaining controllers that need to be made are ResumeController, ResumeSectionController and ResumeSectionItemController. Those will all follow the same template we used in the PhoneController. Make a create, get one, get multiple, update and delete method for each. I won’t go into that here since it is pretty much going to be the same stuff we did with the PhoneController. You’ll need to set constraints and groups on each on the remaining entities as well. They’ll all use constraints we already used previously. If you need help though, feel free to take a look at the github repo for the completed code.

NOTE:
If you add $resumeSections to the api group in your Resume entity, don’t add the $resume property in your ResumeSection entity to the api group. This will cause a circular reference error. The same thing goes for the $resumeSectionItems in ResumeSection. If you add $resumeSectionItems to the api group, and if you also in the add $resumeSection in ResumeSectionItem entity to the api group, a circular reference error will occur.

Before moving on to the next step, be sure to create a controller for each of the other entities.

Step 12: Some minor tweaks

One major thing you are going to run into is when you try to add a ResumeSection, you’ll need to pass a Resume object. You can try to pass the ID but that will give you an error. In this part we’ll go over a way to solve that issue.

We’re going to override the setProperties method in the ResumeSectionService to load an actual Resume entity if a number is passed in to the resume property. To do that we’ll need a ResumeRepository. So, we’ll also need to override the constructor.

We’ll start with the constructor. Create a constructor with the same parameters as the BaseEntityService: ObjectManager and ValidationService. Then add an additional parameter for the ResumeRepository. You’ll need to import these 3 classes at the top of your file as well:

use App\Service\ValidationService;
use App\Repository\ResumeRepository;
use Doctrine\Common\Persistence\ObjectManager;

Create a protected property called $resumeRepository that will be used to store the value that’s passed into the constructor. Pass the ObjectManager and ValidationService into the parent constructor and you should be all set. Here’s what your constructor should look like after those changes:

public function __construct(ObjectManager $om, ValidationService $validator, ResumeRepository $resumeRepository)
{
   parent::__construct($om, $validator);

   $this->resumeRepository = $resumeRepository;
}

Next let’s work on the setProperties method. In here we want to check if the $properties array that’s passed in has a key called resume, and if it does we want to check if it’s a number. Also, just to be super safe, we’ll also check that a user key is also passed. That way we are making sure the resume belongs to the user changing it.

If we find the user and resume keys on $properties and the resume value is a number, we want to use the $resumeRepository to get the actual Resume entity and replace the resume key with it. Once that’s done we want to continue doing what the setProperties method normally does. Try writing that code out on your own and take a look at mine if you need help:

public function setProperties($properties = [])
{
   if(isset($properties['resume']) && is_numeric($properties['resume']))
   {
	   $resumeId = $properties['resume'];
	   $properties['resume'] = $this->resumeRepository->findOneBy(['id' => $resumeId]);
   }

   return parent::setProperties($properties);
}

Now if you try to add a resume section in Postman, you should be able to pass the resume ID and it will add. Here’s my Postman setup for creating a resume section if you need help with it:

We will have to do the same thing inside ResumeSectionItemService, except this time with the ResumeSection. Let’s get started on that now.

We need to override the constructor and setProperties methods again. Copy the same way we set it up in the ResumeSectionService, except this time we will need a ResumeSectionRepository instead of a ResumeRepository.

Here’s what your constructor should look like:

public function __construct(ObjectManager $om, ValidationService $validator, ResumeSectionRepository $resumeSectionRepository)
{
   parent::__construct($om, $validator);

   $this->resumeSectionRepository = $resumeSectionRepository;
}

The setProperties method will also look similar except we’ll need to do some minor tweaks. The ResumeSection entity does not have a user property. The way we know which user it belongs to is by looking at the resume. So, what we need to do is find the ResumeSection using just the id. Then check to make sure the user matches the user that the Resume belongs to. We can’t easily do that without making a custom query, so we’ll do it in code.

Get the ResumeSection entity like this:

$resumeSectionId = $properties['resumeSection'];
$resumeSection = $this->resumeSectionRepository->findOneBy(['id' => $resumeSectionId]);

Then we’ll check to make sure something came back and if it did, well call ->getResume()->getUser(). Then we’ll compare the user returned from that call with the user that’s passed in as a property. If all goes well, we’ll replace the resumeSection key with the actual resume section. Otherwise we’ll unset the resumeSection key. We could also unset the user key but it shouldn’t be an issue as the main setProperties method handles filtering that out.

Try to write that out yourself and then take a look at what I came up:

public function setProperties($properties = [])
{
   if(isset($properties['resumeSection'], $properties['user']) && is_numeric($properties['resumeSection']))
   {
	   $resumeSectionId = $properties['resumeSection'];
	   $resumeSection = $this->resumeSectionRepository->findOneBy(['id' => $resumeSectionId]);
	   if($resumeSection)
	   {
		   $resumeUser = $resumeSection->getResume()->getUser();
		   if($resumeUser == $properties['user'])
		   {
			   $properties['resumeSection'] = $resumeSection;
		   }
		   else
		   {
			   unset($properties['resumeSection']);
		   }
	   }
	   else
	   {
		   unset($properties['resumeSection']);
	   }

   }

   return parent::setProperties($properties);
}

It’s not the prettiest, but it works! Here’s a test in Postman to make sure we can add a resume section item:

One other thing I want to point out is the position properties for ResumeSection and ResumeSectionItem are not being passed in the request. That’s because the package we installed handles all of that positioning mess. If we do want to update the position, we can send it in as another request variable and it will rearrange everything for us! If you check phpMyAdmin or MySQL workbench, you’ll see how it’s working. The first record added gets set to 0, then automatically the next record is set to 1 and so on. One other trick to note is that if you ever want to push something to the last position, pass -1 as the position. The package will handle figuring out what the actual position number should be.

With that said, our routes are now finished! We can now use this api to build our frontend app!

Hopefully you were able to take something away from this series of tutorials. In the next tutorial we will work on configuring our server to allow requests from a frontend app to our backend app. Then we’ll get started on a frontend app!

If you have any questions or feedback feel free to leave a comment below!