Building a Full-Stack Web Application with Symfony API Platform and React

Step 1: Setting up the Symfony Backend with API Platform

We’ll start by setting up a new Symfony project with API Platform to create our backend API.

  • Install Symfony and API Platform
    • Use Composer to create a new Symfony project with API Platform:
      composer create-project symfony/skeleton:"^6.2" my-api
      cd my-api
      composer require api
  • Configure the Database
    • Update your .env file with your database credentials:
      DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=8.0"
  • Create an Entity
    • Generate a new entity class, for example, a Book entity:
      <?php
      
      namespace App\Entity;
      
      use ApiPlatform\Metadata\ApiResource;
      use Doctrine\ORM\Mapping as ORM;
      
      #[ORM\Entity]
      #[ApiResource]
      class Book
      {
          #[ORM\Id]
          #[ORM\GeneratedValue]
          #[ORM\Column]
          private ?int $id = null;
      
          #[ORM\Column(length: 255)]
          private ?string $title = null;
      
          #[ORM\Column(type: 'text')]
          private ?string $description = null;
      
          // Getters and setters...
      }

Step 2: Creating API Endpoints

API Platform automatically creates CRUD endpoints for our entities. Let’s customize an endpoint.

  • Customize API Resource
    • Modify the Book entity to customize the API resource:
      <?php
      
      namespace App\Entity;
      
      use ApiPlatform\Metadata\ApiResource;
      use ApiPlatform\Metadata\Get;
      use ApiPlatform\Metadata\GetCollection;
      use ApiPlatform\Metadata\Post;
      use ApiPlatform\Metadata\Put;
      use ApiPlatform\Metadata\Delete;
      use Doctrine\ORM\Mapping as ORM;
      
      #[ORM\Entity]
      #[ApiResource(
          operations: [
              new Get(),
              new GetCollection(),
              new Post(),
              new Put(),
              new Delete(),
          ],
          paginationEnabled: false
      )]
      class Book
      {
          // ...
      }

Step 3: Setting up the React Frontend

Now, let’s create a React application to consume our API.

  • Create React App
    • Use Create React App to set up a new React project:
      npx create-react-app my-app
      cd my-app
  • Install Axios for API Calls
    • Install Axios to make HTTP requests to our Symfony API:
      npm install axios

Step 4: Implementing API Calls from React

Let’s create a component to fetch and display books from our API.

  • Create a BookList Component
    • Create a new file src/components/BookList.js:
      import React, { useState, useEffect } from 'react';
      import axios from 'axios';
      
      const BookList = () => {
        const [books, setBooks] = useState([]);
      
        useEffect(() => {
          const fetchBooks = async () => {
            try {
              const response = await axios.get('http://localhost:8000/api/books');
              setBooks(response.data['hydra:member']);
            } catch (error) {
              console.error('Error fetching books:', error);
            }
          };
      
          fetchBooks();
        }, []);
      
        return (
          <div>
            <h1>Book List</h1>
            <ul>
              {books.map(book => (
                <li key={book.id}>{book.title}</li>
              ))}
            </ul>
          </div>
        );
      };
      
      export default BookList;

Step 5: Handling CORS for Frontend-Backend Communication

To allow our React frontend to communicate with the Symfony backend, we need to configure CORS (Cross-Origin Resource Sharing).

  • Install Nelmio CORS Bundle
    • In your Symfony project, install the Nelmio CORS Bundle:
      composer require nelmio/cors-bundle
  • Configure CORS
    • Update your config/packages/nelmio_cors.yaml file:
      nelmio_cors:
          defaults:
              origin_regex: true
              allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
              allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
              allow_headers: ['Content-Type', 'Authorization']
              expose_headers: ['Link']
              max_age: 3600
          paths:
              '^/api/':
                  allow_origin: ['*']
                  allow_headers: ['*']
                  allow_methods: ['POST', 'PUT', 'GET', 'DELETE']

Step 6: Adding Authentication to the API

Let’s add JWT authentication to secure our API.

  • Install Lexik JWT Authentication Bundle
    • In your Symfony project, install the JWT Bundle:
      composer require lexik/jwt-authentication-bundle
  • Generate the JWT Keys
    • Generate public and private keys:
      php bin/console lexik:jwt:generate-keypair
  • Configure Security
    • Update your config/packages/security.yaml:
      security:
          enable_authenticator_manager: true
          password_hashers:
              Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
          providers:
              app_user_provider:
                  entity:
                      class: App\Entity\User
                      property: email
          firewalls:
              dev:
                  pattern: ^/(_(profiler|wdt)|css|images|js)/
                  security: false
              login:
                  pattern: ^/api/login
                  stateless: true
                  json_login:
                      check_path: /api/login_check
                      success_handler: lexik_jwt_authentication.handler.authentication_success
                      failure_handler: lexik_jwt_authentication.handler.authentication_failure
              api:
                  pattern:   ^/api
                  stateless: true
                  jwt: ~
          access_control:
              - { path: ^/api/login, roles: PUBLIC_ACCESS }
              - { path: ^/api,       roles: IS_AUTHENTICATED_FULLY }

Step 7: Implementing Authentication in React

Now, let’s add authentication to our React frontend.

  • Create Login Component
    • Create a new file src/components/Login.js:
      import React, { useState } from 'react';
      import axios from 'axios';
      
      const Login = ({ onLogin }) => {
        const [email, setEmail] = useState('');
        const [password, setPassword] = useState('');
      
        const handleSubmit = async (e) => {
          e.preventDefault();
          try {
            const response = await axios.post('http://localhost:8000/api/login_check', {
              email,
              password
            });
            localStorage.setItem('token', response.data.token);
            onLogin(true);
          } catch (error) {
            console.error('Login failed:', error);
          }
        };
      
        return (
          <form onSubmit={handleSubmit}>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="Email"
              required
            />
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              placeholder="Password"
              required
            />
            <button type="submit">Login</button>
          </form>
        );
      };
      
      export default Login;
  • Update API Calls
    • Modify your axios calls to include the JWT token:
      const token = localStorage.getItem('token');
      const config = {
        headers: { Authorization: `Bearer ${token}` }
      };
      
      axios.get('http://localhost:8000/api/books', config)
        .then(response => {
          // Handle response
        });

Step 8: Deploying the Application

Finally, let’s prepare our application for deployment.

  • Prepare Symfony for Production
    • In your Symfony project, clear and warm up the cache:
      APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
      APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
  • Build React for Production
    • In your React project, build the production version:
      npm run build
  • Deploy to a Web Server
    • Upload your Symfony files to your web server.
    • Upload the contents of your React build folder to a static file hosting service or to a directory on your web server.
    • Configure your web server to serve the Symfony application and the React static files.

This completes our tutorial on building a full-stack web application with Symfony API Platform and React. We’ve covered setting up the backend, creating API endpoints, building a React frontend, implementing authentication, and preparing for deployment. Remember to adjust security settings, add error handling, and implement proper user management before deploying to a production environment.

Step 9: Adding Data Validation

Let’s implement data validation for our API endpoints using Symfony’s built-in validation component.

  • Update the Book Entity
    • Add validation constraints to the Book entity:
      <?php
      
      namespace App\Entity;
      
      use ApiPlatform\Metadata\ApiResource;
      use Doctrine\ORM\Mapping as ORM;
      use Symfony\Component\Validator\Constraints as Assert;
      
      #[ORM\Entity]
      #[ApiResource]
      class Book
      {
          #[ORM\Id]
          #[ORM\GeneratedValue]
          #[ORM\Column]
          private ?int $id = null;
      
          #[ORM\Column(length: 255)]
          #[Assert\NotBlank]
          #[Assert\Length(min: 2, max: 255)]
          private ?string $title = null;
      
          #[ORM\Column(type: 'text')]
          #[Assert\NotBlank]
          private ?string $description = null;
      
          // Getters and setters...
      }

Step 10: Implementing Custom Operations

Let’s add a custom operation to our API for searching books.

  • Create a Custom Controller
    • Create a new file src/Controller/BookSearchController.php:
      <?php
      
      namespace App\Controller;
      
      use App\Repository\BookRepository;
      use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
      use Symfony\Component\HttpFoundation\Request;
      use Symfony\Component\HttpFoundation\JsonResponse;
      
      class BookSearchController extends AbstractController
      {
          public function __invoke(Request $request, BookRepository $bookRepository): JsonResponse
          {
              $query = $request->query->get('q');
              $books = $bookRepository->searchByTitle($query);
              
              return $this->json($books);
          }
      }
  • Update the Book Entity
    • Add the custom operation to the Book entity:
      <?php
      
      use ApiPlatform\Metadata\ApiResource;
      use ApiPlatform\Metadata\Get;
      use ApiPlatform\Metadata\GetCollection;
      use ApiPlatform\Metadata\Post;
      use ApiPlatform\Metadata\Put;
      use ApiPlatform\Metadata\Delete;
      
      #[ApiResource(
          operations: [
              new Get(),
              new GetCollection(),
              new Post(),
              new Put(),
              new Delete(),
              new GetCollection(
                  uriTemplate: '/books/search',
                  controller: BookSearchController::class,
                  openapiContext: [
                      'summary' => 'Search for books',
                      'parameters' => [
                          [
                              'name' => 'q',
                              'in' => 'query',
                              'description' => 'Search query',
                              'required' => true,
                              'schema' => ['type' => 'string']
                          ]
                      ]
                  ]
              )
          ]
      )]
      class Book
      {
          // ...
      }

Step 11: Implementing Pagination in React

Let’s add pagination to our BookList component in React.

  • Update BookList Component
    • Modify src/components/BookList.js to include pagination:
      import React, { useState, useEffect } from 'react';
      import axios from 'axios';
      
      const BookList = () => {
        const [books, setBooks] = useState([]);
        const [page, setPage] = useState(1);
        const [totalPages, setTotalPages] = useState(0);
      
        useEffect(() => {
          const fetchBooks = async () => {
            try {
              const response = await axios.get(`http://localhost:8000/api/books?page=${page}`);
              setBooks(response.data['hydra:member']);
              setTotalPages(Math.ceil(response.data['hydra:totalItems'] / response.data['hydra:itemsPerPage']));
            } catch (error) {
              console.error('Error fetching books:', error);
            }
          };
      
          fetchBooks();
        }, [page]);
      
        return (
          <div>
            <h1>Book List</h1>
            <ul>
              {books.map(book => (
                <li key={book.id}>{book.title}</li>
              ))}
            </ul>
            <div>
              <button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous</button>
              <span>Page {page} of {totalPages}</span>
              <button onClick={() => setPage(page + 1)} disabled={page === totalPages}>Next</button>
            </div>
          </div>
        );
      };
      
      export default BookList;

Step 12: Adding Error Handling and Loading States

Let’s improve our React components by adding error handling and loading states.

  • Update BookList Component
    • Modify src/components/BookList.js to include error handling and loading state:
      import React, { useState, useEffect } from 'react';
      import axios from 'axios';
      
      const BookList = () => {
        const [books, setBooks] = useState([]);
        const [page, setPage] = useState(1);
        const [totalPages, setTotalPages] = useState(0);
        const [isLoading, setIsLoading] = useState(false);
        const [error, setError] = useState(null);
      
        useEffect(() => {
          const fetchBooks = async () => {
            setIsLoading(true);
            setError(null);
            try {
              const response = await axios.get(`http://localhost:8000/api/books?page=${page}`);
              setBooks(response.data['hydra:member']);
              setTotalPages(Math.ceil(response.data['hydra:totalItems'] / response.data['hydra:itemsPerPage']));
            } catch (error) {
              setError('An error occurred while fetching books. Please try again later.');
              console.error('Error fetching books:', error);
            } finally {
              setIsLoading(false);
            }
          };
      
          fetchBooks();
        }, [page]);
      
        if (isLoading) return <div>Loading...</div>;
        if (error) return <div>{error}</div>;
      
        return (
          <div>
            <h1>Book List</h1>
            <ul>
              {books.map(book => (
                <li key={book.id}>{book.title}</li>
              ))}
            </ul>
            <div>
              <button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous</button>
              <span>Page {page} of {totalPages}</span>
              <button onClick={() => setPage(page + 1)} disabled={page === totalPages}>Next</button>
            </div>
          </div>
        );
      };
      
      export default BookList;

This concludes our extended tutorial on building a full-stack web application with Symfony API Platform and React. We’ve covered advanced topics such as custom API operations, data validation, pagination, and error handling. These additions will make your application more robust and user-friendly.

Remember to always follow best practices for security, performance, and code organization as you continue to develop your application. Consider adding features like user registration, more complex data relationships, and comprehensive testing to further enhance your project.

Certainly. Let’s continue by adding some more advanced features and best practices to our Symfony API Platform and React application.

Step 13: Implementing Caching

Let’s add caching to our API to improve performance.

  • Configure API Platform Caching
    • Update your config/packages/api_platform.yaml file:
      api_platform:
          # ...
          defaults:
              cache_headers:
                  max_age: 3600
                  shared_max_age: 3600
                  vary: ['Content-Type', 'Authorization', 'Origin']
          collection:
              pagination:
                  items_per_page: 30 # The default number of items per page
  • Add Cache Tags to Entity
    • Update your Book entity to include cache tags:
      <?php
      
      namespace App\Entity;
      
      use ApiPlatform\Metadata\ApiResource;
      use ApiPlatform\Metadata\Cache;
      
      #[ApiResource(
          cacheHeaders: new Cache(maxAge: 3600, public: true)
      )]
      class Book
      {
          // ...
      }

Step 14: Adding Real-time Updates with Mercure

Let’s implement real-time updates using Mercure protocol.

  • Install Mercure Bundle
    • In your Symfony project, install the Mercure bundle:
      composer require symfony/mercure-bundle
  • Configure Mercure
    • Update your .env file:
      MERCURE_URL=http://localhost:3000/.well-known/mercure
      MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
      MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
  • Update API Resource
    • Modify your Book entity to enable Mercure:
      <?php
      
      use ApiPlatform\Metadata\ApiResource;
      use ApiPlatform\Metadata\GetCollection;
      use ApiPlatform\Metadata\Post;
      use ApiPlatform\Metadata\Put;
      
      #[ApiResource(
          mercure: true,
          operations: [
              new GetCollection(),
              new Post(
                  publish: true
              ),
              new Put(
                  publish: true
              )
          ]
      )]
      class Book
      {
          // ...
      }

Step 15: Implementing Real-time Updates in React

Now let’s update our React component to listen for real-time updates.

  • Update BookList Component
    • Modify src/components/BookList.js to include Mercure:
      import React, { useState, useEffect } from 'react';
      import axios from 'axios';
      
      const BookList = () => {
        const [books, setBooks] = useState([]);
      
        useEffect(() => {
          const fetchBooks = async () => {
            try {
              const response = await axios.get('http://localhost:8000/api/books');
              setBooks(response.data['hydra:member']);
            } catch (error) {
              console.error('Error fetching books:', error);
            }
          };
      
          fetchBooks();
      
          const url = new URL('http://localhost:3000/.well-known/mercure');
          url.searchParams.append('topic', 'http://example.com/books/{id}');
      
          const eventSource = new EventSource(url);
          eventSource.onmessage = event => {
            const book = JSON.parse(event.data);
            setBooks(prevBooks => {
              const index = prevBooks.findIndex(b => b.id === book.id);
              if (index !== -1) {
                return [...prevBooks.slice(0, index), book, ...prevBooks.slice(index + 1)];
              } else {
                return [...prevBooks, book];
              }
            });
          };
      
          return () => {
            eventSource.close();
          };
        }, []);
      
        return (
          <div>
            <h1>Book List</h1>
            <ul>
              {books.map(book => (
                <li key={book.id}>{book.title}</li>
              ))}
            </ul>
          </div>
        );
      };
      
      export default BookList;

Step 16: Adding Unit Tests

Let’s add some unit tests to our Symfony application.

  • Install PHPUnit Bridge
    • In your Symfony project, install PHPUnit Bridge:
      composer require --dev symfony/phpunit-bridge
  • Create a Test for Book Entity
    • Create a new file tests/Entity/BookTest.php:
      <?php
      
      namespace App\Tests\Entity;
      
      use App\Entity\Book;
      use PHPUnit\Framework\TestCase;
      
      class BookTest extends TestCase
      {
          public function testSetTitle(): void
          {
              $book = new Book();
              $title = 'Test Book Title';
      
              $book->setTitle($title);
      
              $this->assertEquals($title, $book->getTitle());
          }
      
          public function testSetDescription(): void
          {
              $book = new Book();
              $description = 'Test book description';
      
              $book->setDescription($description);
      
              $this->assertEquals($description, $book->getDescription());
          }
      }
      

Step 17: Adding Integration Tests

Let’s add an integration test for our API.

  • Create an API Test
    • Create a new file tests/Api/BooksTest.php:
      <?php
      
      namespace App\Tests\Api;
      
      use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase;
      use App\Entity\Book;
      
      class BooksTest extends ApiTestCase
      {
          public function testGetCollection(): void
          {
              $response = static::createClient()->request('GET', '/api/books');
      
              $this->assertResponseIsSuccessful();
              $this->assertJsonContains(['@context' => '/api/contexts/Book']);
          }
      
          public function testCreateBook(): void
          {
              $response = static::createClient()->request('POST', '/api/books', ['json' => [
                  'title' => 'The Great Gatsby',
                  'description' => 'A novel by F. Scott Fitzgerald',
              ]]);
      
              $this->assertResponseStatusCodeSame(201);
              $this->assertJsonContains([
                  '@context' => '/api/contexts/Book',
                  '@type' => 'Book',
                  'title' => 'The Great Gatsby',
                  'description' => 'A novel by F. Scott Fitzgerald',
              ]);
          }
      }
      

This concludes our extended tutorial on building a full-stack web application with Symfony API Platform and React. We’ve covered advanced topics such as caching, real-time updates with Mercure, unit testing, and integration testing. These additions will make your application more robust, performant, and maintainable.

Remember to always follow best practices for security, performance, and code organization as you continue to develop your application. Consider adding features like comprehensive error handling, logging, monitoring, and continuous integration/continuous deployment (CI/CD) pipelines to further enhance your project.


Posted

in

, , , ,

by

Tags: