The availability of modern web technology tools has made it extremely easy to build highly scalable web applications. With the access to frontend library like React, you can easily craft a dynamic and reasonable user interface irrespective of the complexity, architecture and scale of your product.
In an earlier article on getting started with React in Symfony using Webpack Encore, we discussed how to easily integrate React into an existing or a new Symfony application. Feel free to go through the article for comprehensive details.
This time, we’ll explore the stunning combination of Symfony and React further by building a product listing or Symfony ecommerce application.
What We Will Build?
The demo is a product listing application. Adding more features, will make it like a mini Symfony ecommerce store. For now, we will reduce the complexity and once done, will have a working application as shown below:
Here, in this demo, you can specify the name, description, price, and an image of your product. Once you click on the Add Product button, the React frontend application will make an HTTP call to the backend API developed in Symfony.
Furthermore, to properly manage image assets, we will employ the service of Cloudinary. Let’s start.
Prerequisites
This tutorial requires the knowledge of React, Object Oriented Programming with PHP and MVC web API patterns. To make the tutorial less difficult for newbies, we will endeavor to break down any complex implementation or logic. You will also need a Cloudinary account, kindly click here to create a free account.
Do ensure that you have Node.js and Yarn package manager installed on your system.
Structure of the Application
This application will have a backend API with a React frontend. The frontend will break into separate, reusable and independent UI components. For the backend, we will use Symfony to accept and process HTTP requests sent in by React.
Now that you have a clear picture of what we want to develop, let’s start by installing Symfony in order to set up the backend API.
Installing and Setting up Symfony for the Backend
To kick-start, use composer to quickly set up a new Symfony project. Alternatively, launch a server on Cloudways best PHP hosting by signing up for free and follow the tutorial to set up a Symfony 4 project in just a few minutes.
To create a new standard Symfony project named product-listing-app and install all its dependencies, run the following command:
composer create-project symfony/website-skeleton product-listing-app
Running the Application
Start the application using the development server:
php bin/console server:run
Navigate to http://localhost:8000/ to see the welcome page:
Building the Symfony API
We will manually develop our own API from scratch. To begin with, we will generate a doctrine entity and controller for the application.
Start Growing with Cloudways PHP Hosting Today
People Love us because we never compromise on hosting
Product Entity
Generate a Product entity by running the command below:
$ php bin/console make:entity
If you are quite conversant with Symfony MakerBundle, you can optionally follow the prompt. Otherwise, locate Product.php within ./src/Entity folder and update it with the below-mentioned content:
// ./src/Entity/Product.php <?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity(repositoryClass="App\Repository\ProductRepository") */ class Product { /** * @ORM\Id() * @ORM\GeneratedValue() * @ORM\Column(type="integer") */ private $id; /** * @ORM\Column(name="product", type="string", length=255) */ private $product; /** * @ORM\Column(name="description", type="string", length=255) */ private $description; /** * @ORM\Column(name="favorite_count", type="integer", nullable=true) */ private $favoriteCount; /** * @ORM\Column(name="price", type="string", length=255, nullable=true) */ private $price; /** * @ORM\Column(name="image_url", type="string", length=255, nullable=true) */ private $imageUrl; /** * @return mixed */ public function getId() { return $this->id; } /** * @return mixed */ public function getProduct() { return $this->product; } /** * @param mixed $product */ public function setProduct($product): void { $this->product = $product; } /** * @return mixed */ public function getDescription() { return $this->description; } /** * @param mixed $description */ public function setDescription($description): void { $this->description = $description; } /** * @return mixed */ public function getFavoriteCount() { return $this->favoriteCount; } /** * @param mixed $favoriteCount */ public function setFavoriteCount($favoriteCount): void { $this->favoriteCount = $favoriteCount; } /** * @return mixed */ public function getPrice() { return $this->price; } /** * @param mixed $price */ public function setPrice($price): void { $this->price = $price; } /** * @return mixed */ public function getImageUrl() { return $this->imageUrl; } /** * @param mixed $imageUrl */ public function setImageUrl($imageUrl): void { $this->imageUrl = $imageUrl; } }
Nothing strange here; we have only specified the required fields for our database.
Setup Product Controller
In the terminal, run the command mentioned below to create the ProductController
$ php bin/console make:controller ProductController
Open the newly created file and replace the index() method with the content below:
/ ./src/ /** * @Route("/", name="default") */ public function index() { return $this->render('product/index.html.twig'); }
Create a new product endpoint
Next, to the index() method, add a function to create products:
/** * @Route("/products/create", methods="POST") */ public function createProducts(Request $request) { $product = new Product; $product->setProduct($request->get('product')); $product->setFavoriteCount(0); $product->setDescription($request->get('description')); $product->setPrice($request->get('price')); $product->setImageUrl($this->imageUploader->uploadImageToCloudinary($request->files->get('image'))); $this->updateDatabase($product); return new JsonResponse($this->productRepository->modify($product)); }
The method above will receive a POST HTTP request from the frontend and will save the details of the newly created products.
Points to note:
You must have noticed the usage of a few custom functions like uploadImageToCloudinary(), and updateDatabase(). Let’s try to understand their purpose:
- uploadImageToCloudinary(): It is meant to easily upload product’s images directly to Cloudinary. It was made and abstracted into a separate class as a service. We will brief this later in this tutorial.
- updateDatabase(): a function to persist and flush data
JSON Response to List All Products
Next, we will create a function to fetch and return the list of all added products. Add the function below to the ProductController for that purpose:
/** * @Route("/products", name="products", methods="GET") * @return \Symfony\Component\HttpFoundation\Response */ public function products() { $products = $this->productRepository->modifyAllProduct(); return $this->response($products); }
The function above will return a JSON response that we will loop through by using React and will display it appropriately for all the users.
Ensure that the top of your controller has all the required declared classes as shown below:
namespace App\Controller; use App\Entity\Product; use App\Service\ImageUploader; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route;
Within the ProductController, we have been referring to a repository named ProductRepository. We will create it later in the tutorial. For now, just before the index(), include the following private properties and add a constructor method:
protected $statusCode = 200; /** * @var EntityManagerInterface */ private $entityManager; /** * @var \App\Repository\ProductRepository|\Doctrine\Common\Persistence\ObjectRepository */ private $productRepository; /** * @var ImageUploader */ private $imageUploader; /** * ProductController constructor. * @param EntityManagerInterface $entityManager * @param ImageUploader $imageUploader */ public function __construct(EntityManagerInterface $entityManager, ImageUploader $imageUploader) { $this->entityManager = $entityManager; $this->productRepository = $entityManager->getRepository('App:Product'); $this->imageUploader = $imageUploader; }
Create a Favorite Count Endpoint
Similar to what is obtainable on most product listings or e-commerce websites, we will add a favorite button to the frontend part of our application. In order to save the number of likes any of our listed products receive, we will create an API for that by adding the method below to the ProductController after the CreateProducts() method:
/** * @Route("/products/{id}/count", methods="POST") */ public function increaseFavoriteCount($id) { $product = $this->productRepository->find($id); if (! $product) { return new JsonResponse("Not found!", 404); } $product->setFavoriteCount($product->getFavoriteCount() + 1); $this->updateDatabase($product); return $this->response($product->getFavoriteCount()); }
What we have done here is to find a particular product using the id, increase the favoriteCount by one (1), and finally persist with the numberOfCount for the selected product.
Add other important functions within this controller:
/** * @param $data * @return JsonResponse */ function response($data) { return new JsonResponse($data, $this->statusCode); } /** * @param $errors * @return JsonResponse */ function responseWithError($errors) { $errorMsg = [ 'errors' => $errors ]; return new JsonResponse($errorMsg, 422); } /** * Accept JSON payload * @param Request $request * @return null|Request */ function acceptJsonPayload(Request $request) { $data = json_decode($request->getContent(), true); if (json_last_error() !== JSON_ERROR_NONE) { return null; } if ($data === null) { return $request; } $request->request->replace($data); return $request; } /** * Persist and flush * @param $object */ function updateDatabase($object) { $this->entityManager->persist($object); $this->entityManager->flush();
In case you missed any of the steps, find the complete ProductController() file here on GitHub
Creating the Service to Upload Images to Cloudinary
Set up Cloudinary Account
Cloudinary will manage the image assets of our application. Cloudinary is a media management platform for images and videos. In case you haven’t created an account yet, click here to start:
Once you are done, log in to your dashboard to obtain your account details:
Proceed to your Cloudinary dashboard and take note of your Cloud name, API Key, and API Secret:
Create ImageUploader Service
To upload files, Cloudinary provides a PHP-SDK to facilitate the process within any PHP application. We will install this SDK via composer by running the command mentioned below:
$ composer require cloudinary/cloudinary_php
Once the installation process is complete, create a new folder within the src folder and name it Service. Within this newly created folder, create a file named imageUploader.php. This will be a PHP class where the process of uploading images to Cloudinary will be configured. Open the newly created file and update the content with:
// ./src/Service/ImageUploader.php <?php namespace App\Service; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImageUploader { public function uploadImageToCloudinary(UploadedFile $file) { $fileName = $file->getRealPath(); \Cloudinary::config([ "cloud_name" => getenv('CLOUD_NAME'), 'api_key' => getenv('API_KEY'), "api_secret" => getenv('API_SECRET') ]); $imageUploaded = \Cloudinary\Uploader::upload($fileName, [ 'folder' => 'symfony-listing', 'width' => 200, 'height' => 200 ]); return $imageUploaded['secure_url']; }
To avoid unnecessary exposure of our Cloudinary credentials, we will save them in the default .env file.
Update the .env file with your Cloudinary credentials
Open ./env and add the details mentioned below to the bottom
# ./env ... #Cloudinary Details CLOUD_NAME=YOUR_CLOUD_NAME API_KEY=YOUR_API_KEY API_SECRET=YOUR_API_SECRET
Don’t forget to replace the YUOR_CLOUD_NAME, YOUR_API_KEY and YOUR_API_SECRET with the appropriate details as obtained from the Cloudinary dashboard.
Create Product Repository
Now, we will create the product repository that we included earlier within our ProductController. One of the major benefits of using repository in any Symfony application is to isolate complex queries from our application controller. It is the best practice because it allows you to separate and abstract business logic into a different file entirely.
If you actually generated your controller using the makerBundle as we have done earlier, it would have created the ProductRepository.php file for you. You can find it in ./src/Repository folder. Open it and use the content below:
// ./src/Repository/ProductRepository.php <?php namespace App\Repository; use App\Entity\Product; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Symfony\Bridge\Doctrine\RegistryInterface; /** * @method Product|null find($id, $lockMode = null, $lockVersion = null) * @method Product|null findOneBy(array $criteria, array $orderBy = null) * @method Product[] findAll() * @method Product[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class ProductRepository extends ServiceEntityRepository { public function __construct(RegistryInterface $registry) { parent::__construct($registry, Product::class); } public function modify(Product $product) { return [ 'id' => (int) $product->getId(), 'product' => (string) $product->getProduct(), 'description' => (string) $product->getDescription(), 'favoriteCount' => (int) $product->getFavoriteCount(), 'price' => (string) $product->getPrice(), 'image' => (string) $product->getImageUrl() ]; } public function modifyAllProduct() { $products = $this->findAll(); $productsArray = []; foreach ($products as $product) { $productsArray [] = $this->modify($product); } return $productsArray; }
Set up and Configure Database
Open up the .env file within your project root directory. You should see something familiar to the following:
###> doctrine/doctrine-bundle ### # Configure your db driver and server_version in config/packages/doctrine.yaml DATABASE_URL=mysql://db_user:[email protected]:3306/db_name ###< doctrine/doctrine-bundle ###
Update the values with the credentials of your MySQL database by replacing:
- db_user with your database username
- db_password with your database password
- db_name with your preferred database name. I have named mine product-listing
Once configured properly, you can use the command below to create your database:
php bin/console doctrine:database:create
Run Migration
Create a migration with:
php bin/console make:migration
And finally:
php bin/console doctrine:migrations:migrate
Building the React Frontend
We have now completed the implementation of logic for the backend API of our application. Now let’s proceed with the frontend.
Install React and other Dependencies
We will use Webpack Encore to set up React. Run the commands below sequentially:
# Create package.json, webpack.config.js and add node_modules to .gitignore composer require symfony/webpack-encore-pack # Add react dependencies yarn add --dev react react-dom prop-types babel-preset-react
In addition, install the dependencies below as well:
yarn add axios reactstrap --save
Configure Symfony Encore
Here, we navigate to ./webpack.config.js to configure Webpack Encore and enable React in our application. Edit the file as shown below:
// ./webpack.config.js
var Encore = require('@symfony/webpack-encore'); Encore .setOutputPath('public/build/') .setPublicPath('/build') .enableReactPreset() .addEntry('js/app', './assets/js/app.js') .cleanupOutputBeforeBuild() .enableBuildNotifications() .enableSourceMaps(!Encore.isProduction()) .enableVersioning(Encore.isProduction()) ; module.exports = Encore.getWebpackConfig();
Finally, in this section, create a subdirectory named js within the assets folder and then add an app.js file inside of it.
Creating the Application Components
We will create a folder to house all the components of our application. To start, create a subdirectory named components within the ./assets/js/ folder.
Add Product
Create the component named ProductForm.js within the components folder and add the following content:
// ./assets/js/components/ProductForm.js import React, { Component } from 'react'; import { Button, Form, FormGroup, Label, Alert, Input } from 'reactstrap'; import { APP } from "../util"; import axios from 'axios'; class ProductForm extends Component { constructor (props) { super(props); this.state = { product: null, description: null, image: null, price: null, errorMessage:null, error: false, isLoading: false }; this.fileChangeHandler = this.fileChangeHandler.bind(this); this.submitForm = this.submitForm.bind(this); } fileChangeHandler(e) { this.setState({ image: e.target.files[0] }); }; submitForm(e) { e.preventDefault(); this.setState({ isLoading: true, error: false, errorMessage: '' }); const body = new FormData(Form); body.append("product", e.target.product.value); body.append("description", e.target.description.value); body.append("price", e.target.price.value); body.append("image", this.state.image); } render() { return ( <Form onSubmit={this.submitForm}> <FormGroup> <label>Product</label> <Input type={'text'} name={'product'} required placeholder='Enter the product name' className={'form-control'}/> </FormGroup> <FormGroup> <label>Description</label> <Input type={'text'} name={'description'} required placeholder='Enter product description' className={'form-control'} /> </FormGroup> <FormGroup> <label>Price</label> <Input type={'text'} name={'price'} required placeholder='Price' className={'form-control'}/> </FormGroup> <FormGroup> <Label for="imageFile">Image</Label> <Input type="file" name="file" id="imageFile" onChange={this.fileChangeHandler}/> </FormGroup> { this.state.error && <Alert color="danger"> {this.state.errorMessage} </Alert> } <Button type='submit' outline color="success">Add Product</Button> { this.state.isLoading && <Alert color="primary"> Loading .... </Alert>} </Form> ) } } export default ProductForm;
This file is an example of a typical React component. What we have done here is import the required module from react and reactstrap. ReactStrap is a Stateless React component for Bootstrap 4. We also imported axios to make API HTTP calls within our component and a custom utility class that contains the BASE_URL for our app.
Next, we set the initial state for our form variables and declare two important methods, which are:
- fileChangeHandler(): We will use it to set the state of the image file for each product
- submitForm(): will handle the form submission and pass the submitted data to the server.
Finally, we used the React render() method to display the product form.
Upload Form Data to the Server
Let’s upload the form to the server. Within the submitForm(), add the method given below just before the closing curly bracket:
... class ProductForm extends Component { ... submitForm(e) { ... this._uploadToServer(body); // add this line } } export default ProductForm;
Next, add the _uploadToServer() method below and place it before the render() method of our ProductForm component:
... class ProductForm extends Component { ... _uploadToServer(body) { axios.post(`${APP.BASE_URL}/${APP.CREATE_PRODUCT_URL}`, body) .then(response => { this.setState({ product: '', isLoading: false, error: false, errorMessage: '' }); this.props.addProduct(response.data) }).catch(err => { this.setState({ isLoading: false, error: true, errorMessage: err.errors }); }); } render() { ... } } export default ProductForm;
Display the list of Products
To display the list of products to users, create another component file within the components folder and name it Products.js. Paste in the content below:
import React, { Component } from 'react'; import ProductForm from './ProductForm'; import { Card, CardImg, CardText, CardBody, CardTitle, Container, Button, Alert,Row, Col, Badge } from 'reactstrap'; import { APP } from '../util' import Favorite from './Favorite'; import axios from 'axios';
class Products extends Component { constructor(props) { super(props); this.state = { products: null, isLoading: null }; this.addProduct = this.addProduct.bind(this); this.favoriteIncrease = this.favoriteIncrease.bind(this); this.getProducts = this.getProducts.bind(this); } favoriteIncrease(data, id) { let products = this.state.products; let product = products.find(product => product.id === id); product.count = data.count; this.setState({ products: products }) } componentDidMount() { this.getProducts(); } getProducts() { if (!this.state.products) { this.setState({ isLoading: true }); axios.get(`${APP.BASE_URL}/${APP.PRODUCTS_URL}`).then(response => { this.setState({ products: response.data, isLoading: false }); }).catch(err => { this.setState({ isLoading: false}); console.log(err) }) } } addProduct(product) { this.setState({ products: [...this.state.products, product] }) } render() { return ( <div> {this.state.isLoading && <Alert color="primary"> Loading .... </Alert>} {this.state.products && <div> <Container> <Row> <Col xs="3"> <ProductForm addProduct={this.addProduct} /> </Col> <Col xl="9"> <Row> {this.state.products.map( product => <Col xs="4" id={product.id} key={product.id}> <Card> <CardImg top width="100%" src={product.image} alt="Card image cap" /> <CardBody> <CardTitle>{product.product}</CardTitle> <h4><Badge color="info" pill>{product.price}</Badge></h4> <CardText>{product.description}</CardText> <Favorite favoriteIncrease={this.favoriteIncrease} productId={product.id} favoriteCount={product.favoriteCount}/> </CardBody> </Card> </Col> )} </Row> </Col> </Row> </Container> </div> } </div> ); } } export default Products;
This component will not only display the list of products, but will also house both the ProductForm component and a favorite button component.
We created three different methods named addProduct(), favoriteIncrease() and getProducts() to bind the details of the product added, increase the count of favorite for each product and to fetch all products on component mount respectively.
Creating the Increase Component to Select Product as Favorite
Next is the Favorite.js component. This component has already been rendered within the Product component. To set it up, create a new file inside the components folder and use the content below:
// ./assets/js/components/Favorite.js import React, { Component } from 'react'; import { Form, Button,Badge } from 'reactstrap' import { APP } from '../util' import axios from 'axios'; class Favorite extends Component { constructor (props) { super(props); this.state = { id: props.productId, count: props.favoriteCount, }; this.onSubmit = this.onSubmit.bind(this); } onSubmit(e) { e.preventDefault(); this.state.count++; axios.post(`${APP.BASE_URL}/${APP.PRODUCTS_URL}/${this.state.id}/count`).then(res => { this.props.favoriteIncrease(res.data, this.state.id) }); } render() { return ( <Form onSubmit={this.onSubmit}> <Button type='submit' color="primary" outline> Favorite <Badge color="secondary">{ this.state.count }</Badge> </Button> </Form> ) } } export default Favorite;
Once we click the favorite button, we will send a POST HTTP request to the server and increase the favorite count for the particular product.
Create Navigation Bar
We will keep things simple and create a file named Navbar.js as the Navigation bar component. Once you are done, add the following content:
import React, { Component } from 'react'; class Navbar extends Component { render(){ return ( <nav className="navbar navbar-expand-lg navbar-dark bg-dark"> <a className="navbar-brand" href="#"> Symfony React Product Listing</a> </nav> ) } } export default Navbar;
Lastly, create a file named util.js within the ./assets/js folder. This will house the endpoint of BASE_URL, CREATE_PRODUCT_URL, and PRODUCTS_URL of our application, respectively. With this in place, we will have a central location for managing all the existing URLs and adding new ones if required. Open the newly created file and add the following code:
export const APP = { BASE_URL: window.location.origin, CREATE_PRODUCT_URL: 'products/create', PRODUCTS_URL: 'products' };
Bringing it all Together by Updating the Main Component
Update the App component within the js folder as shown here :
// ./assets/js/app.js import React from 'react'; import ReactDOM from 'react-dom'; import { Container } from 'reactstrap'; import Navbar from './components/Navbar'; import Products from './components/Products'; class App extends React.Component { render() { return ( <div> <Navbar/> <Container > <Products/> </Container> </div> ) } } ReactDOM.render(<App />, document.getElementById('root'));
We imported both the Navbar and Products components and attached it to render within an HTML element with an ID named root. We will create this in a bit and use it to mount our React application.
Render the Main Component within Twig
To render the main component, open the product homepage and paste the code below in it:
{% extends 'base.html.twig' %} {% block title %} Symfony React Product Listing {% endblock %} {% block body %} <div id="root"></div> {% endblock %}
This template file was automatically generated when we ran a command to create the ProductController earlier. It is found inside ./template/product/index.html
Update the Base Template
Finally, update the base template file by including a link to the CDN file for Bootstrap. Also, add the compiled JavaScript file by Webpack Encore as shown below:
{# ./templates/base.html.twig #} <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}Welcome!{% endblock %}</title> {% block stylesheets %}{% endblock %} <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css"> </head> <body> {% block body %}{% endblock %} {% block javascripts %} <!-- Include the compiled script--> <script type="text/javascript" src="{{ asset('build/js/app.js') }}"></script> {% endblock %} </body> </html>
Try the Application
It’s time to test the application. If you had started the server earlier, the application should work fine for you. But if otherwise, open your terminal within the project directory and run the command below to start the development server:
php bin/console server:run
Once the process mentioned above is complete, open another terminal and compile the React application using Webpack Encore by running:
yarn run dev
Now, navigate to http://localhost:8000/ from your favorite browser and start by adding products.
Conclusion
This article serves as a guide to building an awesome application using Symfony as a backend and React to power the frontend logic. Obviously, more features, like Symfony authentication, add-to-cart cart feature, and others, can be added to the demo – that we have built in this post – in order to make it a full-blown mini Symfony ecommerce website.
In addition, for a commercial application, you might want to use an API platform instead of crafting your own API from scratch, as it can be daunting and time-consuming.
We hope that you will find this product listing application helpful and can easily implement the logic learned on your existing or new applications. Feel free to download the complete source here on GitHub and add more features as you deem fit.
And lastly, you can share your thoughts and other issues in the comment section below.
Owais Khan
Owais works as a Marketing Manager at Cloudways (managed hosting platform) where he focuses on growth, demand generation, and strategic partnerships. With more than a decade of experience in digital marketing and B2B, Owais prefers to build systems that help teams achieve their full potential.