DEV Community

Cover image for How We Built Real-Time Booking System with Symfony. #Part 1
Matt Mochalkin
Matt Mochalkin

Posted on

How We Built Real-Time Booking System with Symfony. #Part 1

In today’s fast-paced world, convenience is king. For service-based companies, offering customers immediate access to information about available slots and enabling quick reservations can be a significant differentiator.

We recognized this need and set out to build a robust, real-time free slot reservation chat. Our solution leverages the power of Symfony, integrated with LLM to manage the intricacies of services slot availability and communication.

This series of articles will delve into the architecture, key components, and a simplified view of the code behind our innovative system.

Real-Time Availability and Seamless Booking

Our primary challenge was to provide customers with instant, accurate information about available service slots and allow them to make reservations without friction.

Traditional booking forms can be clunky, and phone calls are often time-consuming. We envisioned a chat interface that would feel natural and responsive, guiding the user through the booking process.

Key requirements included:

Real-time Slot Availability: Displaying up-to-the-minute information on free slots.

  1. Intuitive Chat Interface: A conversational flow for booking.
  2. Scalability: Handling multiple concurrent user requests.
  3. Reliability: Ensuring data consistency for reservations.
  4. Integration: Connecting with our existing service management system.

The Chat Experience

The chat frontend (built with technologies like React, Vue.js, or even a simple JavaScript widget) would interact with these API endpoints.

A typical chat flow might look like this:

`User: “I’d like to book an appointment.”

Chatbot: “Great! What service are you interested in?”

User: “Haircut.”

Chatbot: “And for what date?”

User: “Tomorrow.”

Chatbot: “I found a few slots for tomorrow: 10:00 AM — 10:30 AM, 11:00 AM — 11:30 AM, 2:00 PM — 2:30 PM. Which one would you like?”

User: “11:00 AM.”

Chatbot: “Excellent! Your 11:00 AM slot is tentatively reserved. We’ll send you a confirmation shortly.”
`

The Full LLM-Driven Application Flow

A user sends a message from a chat interface (web widget, mobile app). This message is received by a Symfony Controller, which acts as the single entry point.

The controller immediately passes the user’s message to the Symfony AI Agent (the AgentInterface service), which is configured to communicate with the external LLM.

The AI Agent sends the user’s message and a list of all available tools (discovered via the #[AsTool] attribute) to the External LLM.

The LLM analyzes the request. Based on its understanding, it either:

  • Generates a simple text response.
  • Decides to call one or more of the provided tools (e.g., validate_service).

If the LLM decides to call a tool, it responds to the AI Agent with the tool’s name and arguments. The AI Agent then executes the corresponding method in your ReservationTools service.

The method in the ReservationTools service runs its code, which may involve:

  • Calling the BookingService.
  • Querying the Database for slot availability.
  • Making API calls to external systems.

The result of the tool’s execution is sent back to the AI Agent. The Agent can then send this result back to the LLM for it to generate a final, human-readable response. This ensures the user gets a conversational reply, not just raw data.

The final response from the LLM (which is now in natural language) is sent back through the AI Agent to the Symfony Controller, which returns it to the user’s chat interface.

Asynchronous Processing and Data Persistence

For tasks that don’t require an immediate response from the user (such as confirming a booking, sending a confirmation email, or triggering a payment process), the application dispatches a message to the Symfony Messenger Component. This allows the request to finish quickly for the user, while the heavy lifting is handled in the background.

The Messenger Component processes the message asynchronously. This is crucial for maintaining a responsive user experience.

The component can:

  • Write reservation details to the Database.
  • Make another API Call to the External Service System to finalize the reservation.

This architecture demonstrates a clean separation of concerns: the user-facing controller handles immediate requests, the core service handles business logic, and the Messenger component ensures that long-running tasks are processed efficiently in the background.

The Slot Entity

Our Slot entity represents a specific time block for a service.

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: "App\Repository\SlotRepository")]
class Slot
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: "integer")]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: "App\Entity\ServiceType")]
    #[ORM\JoinColumn(nullable: false)]
    private ?ServiceType $serviceType = null;

    #[ORM\Column(type: "datetime")]
    private ?\DateTimeInterface $startTime = null;

    #[ORM\Column(type: "datetime")]
    private ?\DateTimeInterface $endTime = null;

    #[ORM\Column(type: "boolean")]
    private bool $isBooked = false;

    #[ORM\Column(type: "string", length: 255, nullable: true)]
    private ?string $bookedByCustomerIdentifier = null; // e.g., chat session ID, user ID

    // Getters and setters...
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getServiceType(): ?ServiceType
    {
        return $this->serviceType;
    }

    public function setServiceType(?ServiceType $serviceType): self
    {
        $this->serviceType = $serviceType;
        return $this;
    }

    public function getStartTime(): ?\DateTimeInterface
    {
        return $this->startTime;
    }

    public function setStartTime(\DateTimeInterface $startTime): self
    {
        $this->startTime = $startTime;
        return $this;
    }

    public function getEndTime(): ?\DateTimeInterface
    {
        return $this->endTime;
    }

    public function setEndTime(\DateTimeInterface $endTime): self
    {
        $this->endTime = $endTime;
        return $this;
    }

    public function getIsBooked(): ?bool
    {
        return $this->isBooked;
    }

    public function setIsBooked(bool $isBooked): self
    {
        $this->isBooked = $isBooked;
        return $this;
    }

    public function getBookedByCustomerIdentifier(): ?string
    {
        return $this->bookedByCustomerIdentifier;
    }

    public function setBookedByCustomerIdentifier(?string $bookedByCustomerIdentifier): self
    {
        $this->bookedByCustomerIdentifier = $bookedByCustomerIdentifier;
        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

The ServiceType Entity

This entity represents the different types of services available for booking (e.g., “haircut,” “manicure”). It has a one-to-many relationship back to the Slot entity, allowing you to easily find all slots associated with a particular service type.

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class ServiceType
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: "integer")]
    private ?int $id = null;

    #[ORM\Column(type: "string", length: 255)]
    private ?string $name = null;

    #[ORM\OneToMany(mappedBy: "serviceType", targetEntity: "App\Entity\Slot")]
    private Collection $slots;

    public function __construct()
    {
        $this->slots = new ArrayCollection();
    }

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

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    /**
     * @return Collection<int, Slot>
     */
    public function getSlots(): Collection
    {
        return $this->slots;
    }

    public function addSlot(Slot $slot): self
    {
        if (!$this->slots->contains($slot)) {
            $this->slots[] = $slot;
            $slot->setServiceType($this);
        }
        return $this;
    }

    public function removeSlot(Slot $slot): self
    {
        if ($this->slots->removeElement($slot)) {
            if ($slot->getServiceType() === $this) {
                $slot->setServiceType(null);
            }
        }
        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

The SlotRepository

This repository class contains all the database query logic for the Slot entity.

namespace App\Repository;

use App\Entity\Slot;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityRepository;

#[AsEntityRepository]
/**
 * @extends ServiceEntityRepository<Slot>
 */
class SlotRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Slot::class);
    }

    /**
     * Finds available slots for a given service type and time range.
     *
     * @return Slot[]
     */
    public function findAvailableSlots(string $serviceTypeName, \DateTimeImmutable $start, \DateTimeImmutable $end): array
    {
        return $this->createQueryBuilder('s')
            ->leftJoin('s.serviceType', 'st')
            ->where('st.name = :serviceTypeName')
            ->andWhere('s.isBooked = :isBooked')
            ->andWhere('s.startTime >= :start')
            ->andWhere('s.endTime <= :end')
            ->setParameters([
                'serviceTypeName' => $serviceTypeName,
                'isBooked' => false,
                'start' => $start,
                'end' => $end,
            ])
            ->getQuery()
            ->getResult();
    }
}
Enter fullscreen mode Exit fullscreen mode

symfony/ai-agent component

Choosing the right tool is crucial when integrating Large Language Models (LLMs) into your application. We explored several options and found that the symfony/ai-agent component is the most effective and seamless solution for our real-time booking system.

This powerful component provides a robust and flexible framework for interacting with various LLM models, making it the ideal choice for developers working with Symfony.

Seamless Integration

It’s a first-party Symfony component, meaning it integrates effortlessly with the framework’s architecture, leveraging existing services and configurations.

Flexibility & Extensibility

It offers a flexible API that allows you to connect with different LLM providers (like OpenAI, Google AI, etc.) with minimal changes. You can easily switch between models or use multiple models simultaneously.

Built for Efficiency

The component is designed to handle the complexities of AI interactions, including managing API keys, handling requests, and processing responses, all while maintaining a high level of performance.

Community & Support

As an official Symfony component, it benefits from the strong support of the Symfony community, ensuring it’s well-maintained and regularly updated.

By leveraging symfony/ai-agent, we can ensure our real-time booking system’s AI capabilities are not only powerful but also reliable and maintainable. This component’s design philosophy aligns perfectly with Symfony’s core principles, making it an indispensable part of our tech stack.

It’s time to install it.

Now that we’ve chosen our tool, the next step is to get it up and running. Fortunately, the installation process for symfony/ai-agent is as straightforward as you’d expect from a Symfony component.

To add the symfony/ai-agent component to your project, simply use Composer, the de-facto package manager for PHP. Open your terminal and execute the following command:

composer require symfony/ai-agent
Enter fullscreen mode Exit fullscreen mode

This command will automatically download the component and its dependencies, and it will also update your project’s composer.json and composer.lock files. Once the installation is complete, the symfony/ai-agent component is ready to be configured and used within your Symfony application, paving the way for our powerful new AI features.

It’s important to note a key detail regarding the current installation of the symfony/ai-agent component. Since it is still in its development phase, you may need to adjust your Composer configuration to allow for the installation of development packages.

To ensure the installation is successful, you might need to enable a specific flag in your composer.json file. This tells Composer to allow the installation of packages that are not yet stable.

If your project’s minimum-stability is set to stable, you can change it to dev for the duration of the installation, or more precisely, add the prefer-stable: true flag to prevent all your dependencies from shifting to dev versions.

Here is an example of what your composer.json might look like:

{
    "minimum-stability": "dev",
    "prefer-stable": true
}
Enter fullscreen mode Exit fullscreen mode

This configuration ensures you can install the cutting-edge symfony/ai-agent component while maintaining stability for the rest of your project’s dependencies.

Once the component is installed, you can revert the minimum-stability setting if you wish, though keeping prefer-stable: true is often a good practice for modern Symfony projects. This proactive step helps us stay on the forefront of AI development and integrate the latest tools into our real-time booking system.

MCP Tools or #[AsTool]

To achieve our ambitious goal of a real-time booking system, the next critical step is to build Tools. These are not just any tools; they are the fundamental components that enable our application to communicate effectively with the external LLM model.

The core idea behind this is to give the AI the ability to perform actions within our system. The symfony/ai-agent component uses a powerful Tooling system that acts as the communication bridge. This system defines a structured way for the LLM to understand what functions it can call, what data it needs to perform a task, and what information it will receive in return. This structured communication is often referred to as a Model Context Protocol (MCP), as it provides the necessary context for the model to operate correctly.

In the context of our booking system, these Tools will be essential for tasks such as:

  • Booking a service: A tool that takes parameters like userId, serviceName, and preferredDate and creates a new booking.
  • Checking availability: A tool that queries our database to check if a specific service on specific date is available.
  • Retrieving user information: A tool that fetches a user’s profile details or past bookings to provide personalized assistance.

By building these well-defined, single-purpose Tools, we can give our LLM-powered booking agent the ‘hands’ it needs to interact with our application’s backend. This approach ensures that the AI’s actions are predictable, secure, and fully integrated with our existing business logic.

Now, for the Real Magic

You didn’t think we’d just leave you hanging, did you? We’ve laid the groundwork, we’ve picked the right tools for the job, and we’ve talked a big game about building our a real-time booking system. But as they say, the proof is in the pudding.

In the next part of this series, we’re going to get our hands dirty. We’ll roll up our sleeves and show you exactly how to build and implement these tools that will finally let the LLM do some heavy lifting. You’ll see how to make our AI-Agent more than just a chat bot — we’ll give it the keys to the kingdom.

Stay tuned, because the fun is just getting started.

Top comments (0)