Build a Book Collection API with Symfony

Build a REST API with Symfony 7.4 and PostgreSQL using Doctrine ORM.

Introduction

30 Second Summary

Every app you use stores data somewhere and lets you access it through an API. Whether it is a bookstore, a library catalogue, or a streaming service, the pattern is the same: receive a request, talk to a database, and send back a response.

In this project, you will build a Book Collection API that stores book information in a database and serves it as JSON. You will set up the exact workflow that PHP developers use on the job every day.

What You'll Build

A REST API built with Symfony 7.4 that stores and retrieves books from a PostgreSQL database using Doctrine ORM.

By the end of this project, you'll have:

  • A self-contained workspace folder with PHP, Composer, and the Symfony project that can be deleted without a trace.
  • A Book entity mapped to a PostgreSQL table via a database migration.
  • Two working API endpoints: POST /api/books (create a book) and GET /api/books (list all books).
  • Secret Mission: Add a search endpoint that filters books by author or title using dynamic queries.

Are there any prerequisites?

None. This guide walks you through installing every tool you need, including PHP and Composer. You just need a Windows machine with VS Code, PostgreSQL, and pgAdmin already installed.

Set Up PHP and Composer in a Self-Contained Workspace

Building a Symfony project requires two tools: PHP (the language runtime) and Composer (the dependency manager).

You'll install both into a single folder called C:\symfony-workspace\ so that nothing touches your system globally. When you're done with the project, you can delete that one folder and your machine is back to its original state.

In this step, get ready to:
  • Download and configure PHP 8.2 in a self-contained workspace folder.
  • Install Composer for PHP dependency management.
  • Verify both tools work together before creating the project.
Create the workspace folder and download PHP

PHP doesn't need a traditional installer on Windows. You can download it as a zip file and extract it wherever you want.

  • Press the Windows key to open your search bar.
  • Type Command Prompt and press Enter to open it.
  • Create the workspace folder by running this command:
mkdir C:\symfony-workspace
  • Now download the PHP zip file. Open your browser and go to:
https://windows.php.net/download/
  • Scroll down to the PHP 8.2 section.
  • Find the row labelled VS16 x64 Non Thread Safe and click the Zip link to download it.

Why Non Thread Safe?

NTS (Non Thread Safe) builds are designed for single-threaded use cases like running PHP from the command line. Since you'll use the built-in PHP development server (not Apache), NTS is the correct choice.

  • Once the download finishes, open the zip file in File Explorer.
  • Select all the files inside the zip and extract them into C:\symfony-workspace\php\.
  • Confirm the extraction worked by checking that C:\symfony-workspace\php\php.exe exists.

Seeing a nested folder?

Some zip tools create an extra folder inside, so you end up with C:\symfony-workspace\php\php-8.2.31-nts-Win32-vs16-x64\php.exe. If this happened, move the contents of that inner folder up one level so php.exe sits directly inside C:\symfony-workspace\php\.

Help me fix my PHP folder structure.

Next, you need to configure PHP's settings file. PHP reads a file called php.ini on startup to know which features to enable.

  • In your Command Prompt, navigate to the PHP folder and create the config file by running these commands:
cd C:\symfony-workspace\php
copy php.ini-development php.ini

What does this command do?

PHP ships with a template file called php.ini-development that has sensible defaults for local development. You're copying it to php.ini so PHP actually reads it.

Now you need to enable the extensions that Symfony and PostgreSQL require. By default, most extensions are disabled (commented out with a semicolon).

  • Open C:\symfony-workspace\php\php.ini in a text editor. You can open it in VS Code by running:
code php.ini
  • Press Ctrl+H to open Find and Replace in VS Code.
  • Find the line ;extension_dir = "ext" and remove the leading semicolon so it reads:
extension_dir = "ext"
  • Next, find and uncomment each of the following extension lines by removing the leading ; from each one:
extension=curl
extension=intl
extension=mbstring
extension=openssl
extension=pdo_pgsql
extension=pgsql
extension=sodium

What do these extensions do?

Each extension adds a capability to PHP. pdo_pgsql and pgsql let PHP talk to PostgreSQL. curl enables HTTP requests. mbstring handles multi-byte text. intl provides internationalization support. openssl and sodium handle encryption.

Symfony requires most of these to function. Without them, you'll hit errors during project creation.

  • Save php.ini with Ctrl+S.

Now tell your terminal session where to find php.exe. This only lasts for the current terminal window, so nothing changes system-wide.

  • Switch back to your Command Prompt and set the session PATH by running:
set PATH=C:\symfony-workspace\php;%PATH%
  • Verify PHP is working by running:
php -v
  • You should see output that starts with PHP 8.2.31.

Seeing 'php' is not recognized?

This means the PATH wasn't set correctly. Check that C:\symfony-workspace\php\php.exe exists. If it does, re-run the set PATH command exactly as shown above. Make sure you're in Command Prompt, not PowerShell.

Help me fix my PATH for PHP.

Install Composer locally

Composer is PHP's package manager. It downloads libraries your project depends on (like Symfony itself). You'll install it into the same workspace folder so it stays self-contained.

  • Navigate to the workspace root by running:
cd C:\symfony-workspace
  • Download the Composer installer script by running:
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
  • Run the installer to produce the composer.phar file:
php composer-setup.php
  • Delete the setup file since it's no longer needed:
del composer-setup.php

What is a .phar file?

A .phar file is a PHP archive. It bundles an entire application into a single file that PHP can execute directly. Composer distributes itself this way so you don't need a separate installer.

To run Composer easily from the command line, you need a small batch file that calls composer.phar through PHP.

  • Create the batch wrapper by opening a PowerShell window and running:
Set-Content C:\symfony-workspace\composer.bat '@php "%~dp0composer.phar" %*'

What does this batch file do?

The composer.bat file lets you type composer as a command instead of php composer.phar every time. It tells Windows to run the phar file using PHP from the same folder.

  • Switch back to your Command Prompt and update the PATH to also include the workspace root:
set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH%
  • Verify Composer works by running:
composer -V
  • You should see output showing Composer version 2.x (the exact patch number may differ).

Seeing 'composer' is not recognized?

Make sure the set PATH command includes both C:\symfony-workspace\php and C:\symfony-workspace. Also confirm that C:\symfony-workspace\composer.bat exists. If it doesn't, re-run the Set-Content command in PowerShell.

Help me get Composer working.

Verify the full toolchain

Before moving on, confirm that PHP loaded the PostgreSQL extension and that Composer can run properly.

  • Check that the PostgreSQL extension is loaded by running:
php -m
  • Scroll through the output and confirm that pdo_pgsql appears in the list.
  • Confirm Composer is fully functional by running:
composer about
  • You should see a short description of Composer and its purpose. Both commands succeeding without errors means your toolchain is ready.

Don't see pdo_pgsql in the list?

Open C:\symfony-workspace\php\php.ini again and double-check that extension=pdo_pgsql has no semicolon in front of it. Also verify that extension_dir = "ext" is uncommented. Save the file and re-run php -m.

Help me enable the pdo_pgsql extension.

✔️ Awesome, I've got everything!

Great. Double check you've confirmed all three verifications: php -v shows 8.2.31, composer -V shows version 2.x, and php -m lists pdo_pgsql.

ⓧ I'd like to double check the full setup

Here's what your workspace should look like at this point:

  • C:\symfony-workspace\php\ contains php.exe, php.ini, and the ext\ folder.
  • C:\symfony-workspace\composer.phar exists.
  • C:\symfony-workspace\composer.bat exists with the content:
@php "%~dp0composer.phar" %*

Your php.ini should have these lines uncommented (no semicolon at the start):

extension_dir = "ext"
extension=curl
extension=intl
extension=mbstring
extension=openssl
extension=pdo_pgsql
extension=pgsql
extension=sodium

Your development environment is ready. Next up, you'll use Composer to create a brand new Symfony project and connect it to PostgreSQL.

Create the Symfony Project and Configure PostgreSQL

Your PHP and Composer toolchain is ready. Now it is time to create the actual Symfony project that will become your Book Collection API.

In this step, you will scaffold a Symfony 7.4 project using Composer, wire it to your local PostgreSQL database, and create the database that will store your book data.

In this step, get ready to:
  • Create a Symfony 7.4 project using Composer.
  • Configure the database connection to PostgreSQL.
  • Create the book_collection database from the command line.
Create a new Symfony 7.4 project

Symfony uses Composer to scaffold new projects. The skeleton template gives you a minimal starting point, and then you add the webapp package to pull in everything needed for a web application (including Doctrine ORM for database access).

  • Set your terminal session PATH and create the Symfony project by running these commands:
set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH%
composer create-project symfony/skeleton:"7.4.*" book-api

What do these commands do?

  • set PATH=... tells your current terminal session where to find php and composer. You need to run this every time you open a new terminal window because it only lasts for the current session.
  • composer create-project downloads the Symfony skeleton at version 7.4.* and installs its dependencies into a new book-api folder.
  • Wait for Composer to finish downloading. This may take a minute depending on your internet connection.
  • Move into the new project folder by running:
cd book-api
  • Install the webapp dependencies by running:
composer require webapp

What does the webapp package add?

The webapp meta-package installs Doctrine ORM (for database access), Twig (for templating), the MakerBundle (for code generation), and other common web application packages. It configures everything automatically using Symfony Flex recipes.

  • Composer may prompt you to confirm running recipes during installation. Type y and press Enter when prompted.
  • Confirm the project was created by running:
dir bin\console
  • You should see bin\console listed in the output. This file is the Symfony command-line tool you will use throughout the project.

Seeing an error about missing extensions?

  • If Composer complains about a missing PHP extension, double-check that you uncommented all the required extensions in php.ini (pdo_pgsql, pgsql, openssl, curl, mbstring, intl, sodium).
  • Verify that extension_dir = "ext" is also uncommented in php.ini.
  • After fixing php.ini, close and reopen your terminal, set PATH again, and retry the composer require webapp command.

Still stuck? Help me troubleshoot this Composer extension error.

Configure the PostgreSQL database connection

Symfony uses an environment variable called DATABASE_URL to know where your database lives. You need to point it at your local PostgreSQL instance.

  • Open the project in VS Code by running:
code .
  • In the VS Code Explorer sidebar, click on the .env file in the project root to open it.
  • Find the line that starts with DATABASE_URL=. It looks like this:
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
  • Replace that entire line with:
DATABASE_URL="postgresql://postgres:your-password-here@127.0.0.1:5432/book_collection?serverVersion=16&charset=utf8"
  • Replace your-password-here with the actual password you set for your postgres user when you installed PostgreSQL.
  • If your PostgreSQL version is not 16, change serverVersion=16 to match your installed version (e.g. serverVersion=15).
  • Save the file by pressing Ctrl+S.

What does this connection string mean?

  • postgresql:// tells Doctrine to use the PostgreSQL driver.
  • postgres:your-password-here is the username and password for your database.
  • 127.0.0.1:5432 is your local machine on the default PostgreSQL port.
  • book_collection is the database name that Doctrine will create for you in the next substep.

Not sure what your PostgreSQL password is?

You set this password when you first installed PostgreSQL. If you cannot remember it, open pgAdmin and try connecting to your local server. If that works, the password pgAdmin uses is the one you need here.

Still need help? Help me find my PostgreSQL password.

Create the database

Doctrine can create the database for you from the command line. This saves you from manually creating it in pgAdmin.

  • Return to your terminal (which should still be inside C:\symfony-workspace\book-api) and create the database by running:
php bin/console doctrine:database:create

What does this command do?

php bin/console is the Symfony command runner. The doctrine:database:create command reads the DATABASE_URL from your .env file and creates an empty database with the name you specified (book_collection).

  • You should see a message that says: Created database book_collection for connection named default.
  • Open pgAdmin and refresh your server list. You should see book_collection appear under Databases.

Seeing a connection refused error?

  • Make sure your PostgreSQL service is running. Open Services (search for it in the Start menu) and check that the PostgreSQL service shows Running.
  • Double-check that the password in your .env file exactly matches your PostgreSQL postgres user password.
  • Verify that serverVersion matches your installed PostgreSQL major version number.

Still stuck? Help me debug this database connection error.

Your Symfony project is wired to PostgreSQL and the database is ready. Next up, you will create the Book entity that maps directly to a database table.

Create the Book Entity and Run a Migration

Your Symfony project is connected to PostgreSQL and the empty book_collection database is ready. Now you need to tell Symfony what data it should store in that database.

In Symfony, you define your data structure using an entity class. Doctrine ORM then translates that class into a real database table through a migration. In this step, you will create a Book entity and run the migration to build the table.

In this step, get ready to:
  • Generate a Book entity with title, author, ISBN, and publication year fields.
  • Generate and execute a database migration to create the book table.
  • Confirm the table exists in pgAdmin.
Generate the Book entity using MakerBundle

Symfony's MakerBundle provides code generators that build boilerplate files for you. Instead of writing the entity class by hand, you will run an interactive command that asks for each field's name, type, and length.

  • Start the entity generator by running this command in your terminal:
php bin/console make:entity Book

What will this command ask you?

The command launches an interactive wizard. It will ask you for a property name, a field type, a field length (for strings), and whether the field can be null.

You will add four fields one at a time, then press Enter on an empty prompt to finish.

  • When the wizard asks for the first property name, type title and press Enter.
  • For field type, press Enter to accept the default string.
  • For field length, press Enter to accept the default 255.
  • For nullable, press Enter to accept the default no.
  • When prompted for the next property, type author and press Enter.
  • For field type, press Enter to accept string.
  • For field length, press Enter to accept 255.
  • For nullable, press Enter to accept no.
  • When prompted for the next property, type isbn and press Enter.
  • For field type, press Enter to accept string.
  • For field length, type 13 and press Enter.
  • For nullable, press Enter to accept no.
  • When prompted for the next property, type publishedYear and press Enter.
  • For field type, type integer and press Enter.
  • For nullable, press Enter to accept no.
  • When prompted for another property, press Enter with an empty name to finish.

What did the command create?

The generator created two files. src/Entity/Book.php is the entity class with private properties, getter methods, and setter methods for each field you defined.

src/Repository/BookRepository.php is a helper class that provides methods to query Book records from the database. The id field is added automatically as the primary key with auto-increment.

  • Confirm the files were created by running:
dir src\Entity\Book.php src\Repository\BookRepository.php

You should see both files listed in the output.

Command not found or class error?

Make sure your PATH is set for this terminal session. Run set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH% if you opened a new terminal.

If you see an error about MakerBundle not being installed, run composer require symfony/maker-bundle --dev and try again. The webapp package should have included it, but some configurations differ.

Help me troubleshoot the make:entity command

✔️ Awesome, I've got everything!

Great. Double check you see both src/Entity/Book.php and src/Repository/BookRepository.php in your project before continuing.

ⓧ I'd like to double check the full code

Here is the full src/Entity/Book.php that should have been generated:

<?php

namespace App\Entity;

use App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(length: 255)]
    private ?string $author = null;

    #[ORM\Column(length: 13)]
    private ?string $isbn = null;

    #[ORM\Column]
    private ?int $publishedYear = null;

    public function getId(): ?int
    {
        return $this->id;
    }

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

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getAuthor(): ?string
    {
        return $this->author;
    }

    public function setAuthor(string $author): static
    {
        $this->author = $author;

        return $this;
    }

    public function getIsbn(): ?string
    {
        return $this->isbn;
    }

    public function setIsbn(string $isbn): static
    {
        $this->isbn = $isbn;

        return $this;
    }

    public function getPublishedYear(): ?int
    {
        return $this->publishedYear;
    }

    public function setPublishedYear(int $publishedYear): static
    {
        $this->publishedYear = $publishedYear;

        return $this;
    }
}

And here is src/Repository/BookRepository.php:

<?php

namespace App\Repository;

use App\Entity\Book;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Book>
 */
class BookRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Book::class);
    }
}
Generate and run the migration

The entity class exists in PHP, but the database does not have a matching table yet. A database migration bridges that gap. It generates SQL statements that create the table structure based on your entity's attributes.

  • Generate the migration file by running:
php bin/console make:migration

What does this command do?

Doctrine compares the current state of your entity classes against the current state of the database schema. It then generates a PHP migration file in the migrations/ folder containing the SQL needed to synchronize them.

In this case, it creates a CREATE TABLE statement for the book table.

  • Open the newly created file in the migrations/ folder in VS Code. The filename starts with Version followed by a timestamp.
  • Confirm it contains a CREATE TABLE book statement with columns for id, title, author, isbn, and published_year.

Why is it published_year and not publishedYear?

Doctrine automatically converts camelCase PHP property names to snake_case column names when generating SQL. Your PHP code uses publishedYear, but the database column becomes published_year.

Doctrine handles this translation in both directions, so you never need to think about it in your application code.

  • Execute the migration to create the table by running:
php bin/console doctrine:migrations:migrate
  • When prompted, type yes and press Enter to confirm.

You should see a message confirming the migration was executed successfully.

  • Open pgAdmin and expand Servers in the left tree.
  • Expand PostgreSQL, then Databases, then book_collection.
  • Expand Schemas, then public, then Tables.
  • Confirm you see the book table listed with columns: id, title, author, isbn, published_year.

Migration failed with a connection error?

Check that PostgreSQL is running on your machine. Also verify that the DATABASE_URL in your .env file has the correct password and port for your local PostgreSQL installation.

If you see a "database does not exist" error, go back and run php bin/console doctrine:database:create first.

Help me fix my migration error

Understand what happened

Two generated files now power the connection between your PHP code and the database. Take a moment to look at how they work.

  • In VS Code, open src/Entity/Book.php.

What is an entity?

An entity is a plain PHP class that Doctrine maps to a database table. Each property with an #[ORM\Column] attribute becomes a column. The #[ORM\Entity] attribute at the top of the class tells Doctrine this class should be tracked and synchronized with the database.

The getters and setters let your controller code read and write book data, while Doctrine handles converting it to SQL behind the scenes.

  • Open src/Repository/BookRepository.php.

What is a repository?

A repository is the class you use to fetch entities from the database. It extends Doctrine's ServiceEntityRepository which gives you built-in methods like findAll(), find(), and findBy() without writing any SQL.

In the next step, your controller will use this repository to retrieve books from the database and return them as JSON.

Your data model is defined and the database table is ready. Next up, you will build the API controller that reads and writes books through HTTP endpoints.

Build the API Controller

In the previous step, you created the Book entity and ran a migration to build the book table in your database.

Now you need a way for external applications to actually talk to that database. A controller is the piece of code that receives HTTP requests and returns responses. In this step, you will create a controller with two REST API endpoints: one to list all books and one to create a new book.

In this step, get ready to:
  • Generate a controller class using MakerBundle.
  • Write two API endpoints for listing and creating books.
  • Test the endpoints with curl and confirm data in pgAdmin.
Create the BookApiController

Symfony's MakerBundle can generate a controller class with boilerplate code that you then customize.

  • Generate the controller scaffold by running this command:
php bin/console make:controller BookApiController
  • Confirm the output shows that src/Controller/BookApiController.php was created.

What does this command do?

The make:controller command creates a new PHP class at src/Controller/BookApiController.php with a basic index() method and a route attribute. It also creates a Twig template file, which you won't need for a JSON API.

You will replace all of this generated code with your own API logic in the next substep.

Command not found?

Make sure your PATH is set for this terminal session. Run set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH% then try again.

help me fix a command not found error when running php bin/console

Write the GET endpoint to list all books

The generated file has placeholder code for rendering a Twig template. You will replace it entirely with API logic that returns JSON.

  • In VS Code, open src/Controller/BookApiController.php.
  • Select all the existing code and delete it.
  • Paste the following code, which sets up the class and the GET endpoint:
<?php

namespace App\Controller;

use App\Entity\Book;
use App\Repository\BookRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

// All routes in this controller start with /api/books
#[Route('/api/books')]
class BookApiController extends AbstractController
{
    // GET /api/books - returns all books as JSON
    #[Route('', methods: ['GET'])]
    public function index(BookRepository $bookRepository): JsonResponse
    {
        // Fetch every book from the database
        $books = $bookRepository->findAll();

        // Transform each Book entity into an associative array
        $data = [];
        foreach ($books as $book) {
            $data[] = [
                'id' => $book->getId(),
                'title' => $book->getTitle(),
                'author' => $book->getAuthor(),
                'isbn' => $book->getIsbn(),
                'publishedYear' => $book->getPublishedYear(),
            ];
        }

        return $this->json($data);
    }
}

What does this code do?

The #[Route('/api/books')] attribute on the class sets a URL prefix. Every method inside this controller inherits that prefix.

The index() method uses Symfony's dependency injection to receive a BookRepository instance automatically. It calls findAll() to get every Book entity from the database, then loops through them to build a plain array that $this->json() converts into a JSON response.

  • Save BookApiController.php.

You won't see a change yet. The next chunk adds the POST endpoint, and then you will test both together.

Add the POST endpoint to create books

The second endpoint accepts a JSON request body, creates a new Book entity from it, and saves it to the database.

  • In src/Controller/BookApiController.php, find the closing brace of the index() method.
  • Add the following method below it, inside the class (before the final }):
    // POST /api/books - creates a new book from JSON request body
    #[Route('', methods: ['POST'])]
    public function create(Request $request, EntityManagerInterface $entityManager): JsonResponse
    {
        // Decode the JSON body into a PHP array
        $data = json_decode($request->getContent(), true);

        // Create a new Book entity and set its properties
        $book = new Book();
        $book->setTitle($data['title']);
        $book->setAuthor($data['author']);
        $book->setIsbn($data['isbn']);
        $book->setPublishedYear($data['publishedYear']);

        // Save the book to the database
        $entityManager->persist($book);
        $entityManager->flush();

        // Return the created book with a 201 Created status
        return $this->json([
            'id' => $book->getId(),
            'title' => $book->getTitle(),
            'author' => $book->getAuthor(),
            'isbn' => $book->getIsbn(),
            'publishedYear' => $book->getPublishedYear(),
        ], Response::HTTP_CREATED);
    }

What does this code do?

The create() method receives two injected services: a Request object containing the incoming HTTP data, and an EntityManagerInterface that handles database writes.

It decodes the JSON body, builds a new Book entity, then calls persist() to mark it for saving and flush() to execute the INSERT query. The response includes the new book's auto-generated id and a 201 status code.

  • Save BookApiController.php.

Seeing a red underline in VS Code?

If your editor shows a syntax error, check that the create() method sits inside the class braces. The file should have exactly two closing } at the bottom: one for the method and one for the class.

help me fix a syntax error in my Symfony controller

✔️ Awesome, I've got everything!

Great. Double check you have saved src/Controller/BookApiController.php with both methods.

ⓧ I'd like to double check the full code

Here is the complete src/Controller/BookApiController.php file:

<?php

namespace App\Controller;

use App\Entity\Book;
use App\Repository\BookRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/books')]
class BookApiController extends AbstractController
{
    #[Route('', methods: ['GET'])]
    public function index(BookRepository $bookRepository): JsonResponse
    {
        $books = $bookRepository->findAll();

        $data = [];
        foreach ($books as $book) {
            $data[] = [
                'id' => $book->getId(),
                'title' => $book->getTitle(),
                'author' => $book->getAuthor(),
                'isbn' => $book->getIsbn(),
                'publishedYear' => $book->getPublishedYear(),
            ];
        }

        return $this->json($data);
    }

    #[Route('', methods: ['POST'])]
    public function create(Request $request, EntityManagerInterface $entityManager): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        $book = new Book();
        $book->setTitle($data['title']);
        $book->setAuthor($data['author']);
        $book->setIsbn($data['isbn']);
        $book->setPublishedYear($data['publishedYear']);

        $entityManager->persist($book);
        $entityManager->flush();

        return $this->json([
            'id' => $book->getId(),
            'title' => $book->getTitle(),
            'author' => $book->getAuthor(),
            'isbn' => $book->getIsbn(),
            'publishedYear' => $book->getPublishedYear(),
        ], Response::HTTP_CREATED);
    }
}
Test the API endpoints

With the controller code in place, it is time to start a web server and send real HTTP requests to your endpoints.

  • Start the PHP development server by running this command:
php -S localhost:8000 -t public

What does this command do?

The -S localhost:8000 flag starts PHP's built-in web server on port 8000. The -t public flag sets the document root to the public/ folder, which is where Symfony's front controller (index.php) lives.

This terminal will stay occupied while the server runs. You will open a second terminal for the next commands.

  • Open a new terminal window.
  • Set the PATH in this new terminal so it can find php and curl:
set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH%
  • Navigate into your project folder:
cd C:\symfony-workspace\book-api
  • Test the POST endpoint by creating a book with this command:
curl -X POST http://localhost:8000/api/books -H "Content-Type: application/json" -d "{\"title\":\"The Pragmatic Programmer\",\"author\":\"David Thomas\",\"isbn\":\"9780135957059\",\"publishedYear\":2019}"
  • You should see a JSON response containing the book you just created, with an id field and an HTTP 201 status.
  • Test the GET endpoint by running:
curl http://localhost:8000/api/books
  • You should see a JSON array containing the book you created in the previous command.
  • Open pgAdmin and navigate to the book_collection database.
  • Expand Schemas > public > Tables > book, then right-click book and select View/Edit Data > All Rows.
  • Confirm the row with title The Pragmatic Programmer appears in the table.

Getting a connection refused error?

Make sure the PHP development server is still running in the first terminal. If you closed it, go back to that terminal and run php -S localhost:8000 -t public again from the book-api folder.

If you see a 404 error instead, double check that BookApiController.php is saved and that the route attribute says #[Route('/api/books')] on the class.

help me debug why my Symfony API endpoint returns an error

Your API is live and responding to requests. In the Secret Mission, you will add a search endpoint that filters books by author or title using Doctrine's QueryBuilder.

Secret mission

Add Search Functionality

Your API can create and list books, but what about finding a specific one? In this secret mission, you'll add a search endpoint that filters books by author or title using Doctrine's QueryBuilder. This introduces dynamic query building, a pattern you'll use constantly in production Symfony apps.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The book_collection PostgreSQL database with a book table.
  • The C:\symfony-workspace\ folder containing PHP, Composer, and the Symfony project.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your Book Collection API.

  • Your database and workspace folder remain in place for further learning.
  • Remember that the PHP development server only runs while you have the terminal open. You'll need to restart it when you come back.

Pause - I'll come back to this later

Shut down the running development server to free up the terminal, but keep all your files and database intact.

  • In the terminal running the PHP development server, press Ctrl+C to stop it.
  • To resume later, open a terminal in C:\symfony-workspace\book-api, set your PATH, and start the server again:
set PATH=C:\symfony-workspace\php;C:\symfony-workspace;%PATH%
php -S localhost:8000 -t public

Delete - I don't want to use this again

Remove all project resources and start fresh. Nothing was installed system-wide, so deleting these two things leaves your machine exactly as it was before.

  • Drop the database in pgAdmin. Right-click book_collection in the left sidebar, then select Delete/Drop.
  • Delete the workspace folder by running this in a terminal:
rmdir /s /q C:\symfony-workspace

That's it. PostgreSQL and pgAdmin remain installed (they were already on your machine), but the project database and all PHP/Composer/Symfony files are gone.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional REST API with Symfony and PostgreSQL, from a blank folder to live endpoints returning JSON data.

You've learned how to:

  • Set up a self-contained PHP 8.2 and Composer environment on Windows with zero system-wide installs, using session-scoped PATH variables.
  • Create a Symfony 7.4 project from scratch, configure Doctrine ORM to connect to PostgreSQL, and generate database migrations from entity classes.
  • Build REST API endpoints in a Symfony controller using attribute-based routing, dependency injection, and the EntityManager to persist and retrieve data.
  • Secret Mission: Add search functionality with Doctrine QueryBuilder for dynamic filtering by author or title using LIKE queries.

Ready to quiz yourself?