Organizing SCRUD code in Symfony 5

Writing SCRUD (search, create, read, update, delete) code is a very common thing to do when working on your back-end. The SCRUD logic spans the entire MVC (Model, View, Controller) architecture, all the way from API endpoints to database systems.

This post will describe how I organize this code in my Symfony applications to make it clear and performant. The use case for this example is simple: I want to manage blog posts in a database using an API. 

It always starts with the database layer. Symfony leverages a tool called Doctrine to abstract away interactions with the database. You just create an object called an Entity, and Doctrine will take care of giving you an API to manage the rows of the relevant tables.

src\Entity\BlogPost.php

<?php 
namespace App\Entity; 

use Doctrine\ORM\Mapping as ORM;

/** * @ORM\Entity 
* @ORM\Table(name="Text") 
*/ 
class Text{    
/**     
* @ORM\Column(type="integer")     
* @ORM\Id     
* @ORM\GeneratedValue(strategy="AUTO")     
*/    
protected $id;    

/**     
* @ORM\Column(type="string", length=50, unique=true, nullable=true)     
*/    
protected $uuid;    

/**     
* @ORM\Column(type="datetime", nullable=true)     
*/    
protected $published_datetime;    

/**     
* @ORM\Column(type="string", length=255, nullable=true)     
*/    
protected $title;    

/**     
* @ORM\Column(type="text", nullable=true)     
*/    
protected $content;    

public function __construct(){        
    $this->uuid = Uuid::uuid4()->toString();    
}    

// GETTERS    
public function getId(){        
    return $this->id;    
}    

public function getUUId(){        
    return $this->uuid;    
}    

public function getPublishedDatetime(){        
    return $this->published_datetime;    
}    

public function getTitle(){        
    return $this->title;    
}    

public function getContent(){        
    return $this->content;    
}    

// SETTERS    
public function setPublishedDatetime($published_datetime){     
    $this->published_datetime = $published_datetime;        
    return $this;    
}    

public function setTitle($title){        
    $this->title = $title;       
    return $this;    
}    

public function setContent($content){        
    $this->content = $content;        
    return $this;    
}    

// FUNCTIONS    
public function toArray(){
return [            
    'id' => $this->id,            
    'uuid' => $this->uuid,            
    'published_datetime' => $this->getPublishedDatetime()->format('Y-m-d H:i:s'),            
    'title' => $this->title,            
    'content' => $this->content        
];    
}    

public function toJSON(){        
    return json_encode($this->toArray());    
}    

public function __toString(){        
    return json_encode($this->toArray());    
} 

} 
?>

The Entity classes contain table fields in the form of protected properties, as well as getters and setters. I also usually add some functions, like toArray(), which are often used to send data to a Javascript client. Doctrine can consume Entity classes to generate SQL code that will be used to manage our database.

We need another layer to perform the SCRUD functions themselves, also known as the Service layer.

In Symfony 5, we can split the service logic into two classes: a Repository to retrieve data from the database (search and read functions), and a Service class to create and update our entities while performing type checks.

/src/Repository/BlogPostRepository.php

<?php 
namespace App\Repository; use App\Entity\BlogPost; 

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; 
use Doctrine\Common\Persistence\ManagerRegistry; 

class BlogPostRepository extends ServiceEntityRepository {    

public function __construct(ManagerRegistry $registry)    {        
    parent::__construct($registry, BlogPost::class);    
}    

public function search($args){        
    $filters = '';        
    if(isset($args['query'])){              
        $filters .= "title LIKE '%:query%'";        
    }        
    $limit = !empty($args['limit']) && is_integer($args['limit']) ? $args['limit'] : 20;        
    $offset = !empty($args['offset']) && is_integer($args['offset']) ? $args['offset'] * $limit : 0;        

    $conn = $this->getEntityManager()->getConnection();        
    $stmt = $conn->prepare("        
        SELECT        
        *        
        FROM BlogPost p        
        WHERE ". $filters ."        
        ORDER BY published_datetime DESC        
        LIMIT {$limit} OFFSET {$offset}        
    ");        

    if(isset($args['query'])){
        $stmt->bindValue(':query', $args['query']);
    }

    $stmt->execute();        
    return $stmt->fetchAll();    
} 
}

The Repository class allows us to query data using native (findOne, findOneByTitle, findAll, etc.) or custom functions (search). I often write custom functions using raw SQL to improve the performance of my queries. For complex requests using several joins, the speed can be increased twofold by not relying on the ORM abstraction. It’s often important to decrease the loading time of intricate webpages.

/src/Service/BlogPostService.php

<?php 
namespace App\Service; 

use App\Entity\BlogPost; 

class BlogPostService{    

public function create($args){        
    return $this->setFields(new BlogPost(), $args);    
}    

public function update(Text $entity, $args){        
    return $this->setFields($entity, $args);    
}    

private function setFields($entity, $args){        
    if(isset($args['title'])){            
        $entity->setTitle(htmlspecialchars($args['title']));        
    }        
    if(isset($args['content'])){     
        $entity->setContent($args['content']);        
    }        
    if(!empty($args['published_datetime'])){            
        $entity->setPublishedDatetime($args['published_datetime']);        
    }        
    return $entity;    
} 
}

The Service class calls the relevant setters to change a database row’s state. Each function returns the updated entity to be persisted in the database by the entity manager at the Controller layer.

/src/Controller/API/BlogPostController.php

<?php 
namespace App\Controller\API; 

use Symfony\Component\Routing\Annotation\Route; 
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 
use Symfony\Component\HttpFoundation\Request; 
use Symfony\Component\HttpFoundation\JsonResponse; 
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted; 

use App\Entity\BlogPost; 
use App\Service\BlogPostService; 
use App\Repository\BlogPostRepository; 

/** 
* @Route("/api/blog-posts") 
*/ 
class BlogPostController extends AbstractController {   
 
/**    
* @Route("/", name="api_blog_posts_search", methods={"GET"})    
* @IsGranted("ROLE_USER")    
*/    
public function api_blog_posts_search(        
    Request $request,        
    TextRepository $repository    
){        
    try{            
        return new JsonResponse(['ok' => true, 'posts' => $repository->search(['query' => $request->query->get('query')])], 200);        
    } catch(\Exception $e){            
        return new JsonResponse(['ok' => false, 'error' => $e->getMessage()], 500);        
    }    
}    

/**    
* @Route("/{uuid}", name="api_blog_posts_read_one", methods={"GET"})    
* @IsGranted("ROLE_USER")    
*/    
public function api_blog_posts_read_one(        
    BlogPost $post,        
    Request $request    
){        
    try{            
        return new JsonResponse(['ok' => true, 'post' => $post->toArray()], 200);        
    } catch(\Exception $e){            
        return new JsonResponse(['ok' => false, 'error' => $e->getMessage()], 500);        
    }    
}    

/**    
* @Route("/", name="api_blog_post_create", methods={"POST"})    
* @IsGranted("ROLE_USER")    
*/    
public function api_blog_post_create(        
    Request $request,        
    BlogPostService $service    
){        
    try{            
        $post = $service->create([                
            'title' => $request->request->get('title'),                
            'content' => $request->request->get('content')            
        ]);            
        
        $em = $this->getDoctrine()->getManager();         
        $em->persist($post);            
        $em->flush();            
        
        return new JsonResponse(['post' => $post->toArray()], 200);        
    } catch(\Exception $e){            
        return new JsonResponse(['ok' => false, 'error' => $e->getMessage()], 500);        
    }    
}    

/**    
* @Route("/{uuid}", name="api_blog_post_update", methods={"PUT"})    
* @IsGranted("ROLE_USER")    
*/    
public function api_blog_post_update(        
    BlogPost $post,        
    Request $request,        
    BlogPostService $service    
){        
    try{                
        $params = json_decode($request->getContent(), true)['text'];                
        $post = $service->update($post, [                    
            'title' => $params['title'],                    
            'content' => $params['content']                
        ]);                

        $em = $this->getDoctrine()->getManager();                
        $em->persist($post);                
        $em->flush();            

        return new JsonResponse(['ok' => true], 200);        
    } catch(\Exception $e){            
        return new JsonResponse(['ok' => false, 'error' => $e->getMessage()], 500);        
    }    
}    

/**    
* @Route("/{uuid}", name="api_blog_post_delete", methods={"DELETE"})    
* @IsGranted("ROLE_USER")    
*/    
public function api_blog_post_delete(BlogPost $post){        
    try{            
        $em = $this->getDoctrine()->getManager();            
        $em->remove($post);            
        $em->flush();            
        return new JsonResponse(['ok' => true], 200);        
    } catch(\Exception $e){            
        return new JsonResponse(['ok' => false, 'error' => $e->getMessage()], 500);        
    }    
} 
}

The Doctrine manager has to be invoked in the controller to avoid any premature interaction with the database that would result in a loss of performance. If we need to update several tables in the future, the flush() function will only be called once thanks to this design. This is also why there is no delete() function in the Service layer, since it only necessitates a call to the entity manager.

And voilà, that’s how one can obtain a functional RESTful API using clear concise code with Symfony 5.