Custom Booking System vs SaaS Booking Tools: Which Is Better Value for Service Businesses

13 min read 2,409 words
Custom Booking System vs SaaS Booking Tools: The Real Comparison for Service Businesses featured image

What the Repository Pattern Actually Does in PHP

If you have ever worked on a PHP project that grew beyond a few scripts, you have probably encountered a common problem. Business logic gets tangled with database calls. Testing becomes difficult because the code relies directly on a database connection. Changing the data source means rewriting code scattered across the application. The repository pattern addresses these issues directly.

The repository pattern creates an abstraction layer between your business logic and the code that retrieves or stores data. Instead of calling mysqli_query or using Eloquent models throughout your code, you work with repository objects that define how data is accessed. The rest of your application does not need to know whether that data comes from MySQL, PostgreSQL, a REST API, or a file system.

This separation has practical consequences for maintainability, testing, and long-term development. Understanding how to implement it properly helps when building custom booking systems, business dashboards, or any PHP application that needs to evolve over time.

Why Separation Matters in PHP Applications

PHP applications often start small. A few scripts handle everything: database connections, HTML rendering, business rules, and data validation all live together in the same files. This approach works fine for prototypes and simple tools. It becomes problematic as the application grows.

When database calls are scattered throughout the code, changing the database structure requires updating dozens of locations. Testing business logic requires a working database with test data. Adding a new data source means duplicating the access logic. These friction points slow down development and increase the chance of errors.

The repository pattern addresses this by centralising data access logic in one place. Business logic receives data through repository methods and does not care how that data was retrieved. If you need to switch from MySQL to PostgreSQL, you update one repository class. The business logic continues working unchanged.

Testability and the Ability to Mock Data Sources

One of the strongest arguments for the repository pattern is how it improves testing. When your business logic depends directly on database classes, you cannot run unit tests without a database connection. Tests become slow, fragile, and dependent on external infrastructure.

With a repository interface, you can create mock implementations that return test data without touching any database. Your business logic receives the same data format whether it comes from a real database or a mock object. This makes unit tests fast, reliable, and independent of external services.

Flexibility When Data Sources Change

Business requirements change. A service that initially stored customer data in MySQL might need to synchronise with an external CRM through an API. An application that started with local storage might need to migrate to a cloud database. These transitions are simpler when data access is centralised in repositories.

Instead of rewriting every part of the application that touches data, you create a new repository implementation. The repository interface remains the same. The business logic continues using familiar methods like findById or getByCustomer without knowing that the data source has changed.

Building a Repository Interface in PHP

The pattern starts with an interface that defines what operations the business logic needs. This interface lives in a location accessible to both the business logic layer and the data access layer. It does not depend on any specific database technology.

interface BookingRepositoryInterface
{
    public function findById(int $id): ?Booking;
    public function findByCustomer(int $customerId): array;
    public function findByDateRange(\DateTime $start, \DateTime $end): array;
    public function save(Booking $booking): bool;
    public function delete(int $id): bool;
}

This interface describes what the business logic needs without specifying how it works. Any class that implements this interface can serve as a data source. The business logic does not need to know whether the implementation uses MySQL, PostgreSQL, or an API.

A MySQL Implementation

Here is how a MySQL implementation might look using PDO. The class implements the same interface and handles all the database-specific logic.

class MySqlBookingRepository implements BookingRepositoryInterface
{
    private \PDO $connection;

    public function __construct(\PDO $connection)
    {
        $this->connection = $connection;
    }

    public function findById(int $id): ?Booking
    {
        $statement = $this->connection->prepare(
            'SELECT * FROM bookings WHERE id = :id'
        );
        $statement->execute(['id' => $id]);
        $row = $statement->fetch(\PDO::FETCH_ASSOC);

        if ($row === false) {
            return null;
        }

        return $this->hydrateBooking($row);
    }

    public function findByCustomer(int $customerId): array
    {
        $statement = $this->connection->prepare(
            'SELECT * FROM bookings WHERE customer_id = :customer_id ORDER BY date DESC'
        );
        $statement->execute(['customer_id' => $customerId]);

        $bookings = [];
        while ($row = $statement->fetch(\PDO::FETCH_ASSOC)) {
            $bookings[] = $this->hydrateBooking($row);
        }

        return $bookings;
    }

    public function findByDateRange(\DateTime $start, \DateTime $end): array
    {
        $statement = $this->connection->prepare(
            'SELECT * FROM bookings WHERE date BETWEEN :start AND :end ORDER BY date'
        );
        $statement->execute([
            'start' => $start->format('Y-m-d'),
            'end' => $end->format('Y-m-d')
        ]);

        $bookings = [];
        while ($row = $statement->fetch(\PDO::FETCH_ASSOC)) {
            $bookings[] = $this->hydrateBooking($row);
        }

        return $bookings;
    }

    public function save(Booking $booking): bool
    {
        if ($booking->getId() === null) {
            return $this->insert($booking);
        }
        return $this->update($booking);
    }

    public function delete(int $id): bool
    {
        $statement = $this->connection->prepare(
            'DELETE FROM bookings WHERE id = :id'
        );
        return $statement->execute(['id' => $id]);
    }

    private function hydrateBooking(array $row): Booking
    {
        return new Booking(
            (int) $row['id'],
            (int) $row['customer_id'],
            new \DateTime($row['date']),
            $row['service'],
            (float) $row['price']
        );
    }

    private function insert(Booking $booking): bool
    {
        $statement = $this->connection->prepare(
            'INSERT INTO bookings (customer_id, date, service, price) VALUES (:customer_id, :date, :service, :price)'
        );
        return $statement->execute([
            'customer_id' => $booking->getCustomerId(),
            'date' => $booking->getDate()->format('Y-m-d'),
            'service' => $booking->getService(),
            'price' => $booking->getPrice()
        ]);
    }

    private function update(Booking $booking): bool
    {
        $statement = $this->connection->prepare(
            'UPDATE bookings SET customer_id = :customer_id, date = :date, service = :service, price = :price WHERE id = :id'
        );
        return $statement->execute([
            'id' => $booking->getId(),
            'customer_id' => $booking->getCustomerId(),
            'date' => $booking->getDate()->format('Y-m-d'),
            'service' => $booking->getService(),
            'price' => $booking->getPrice()
        ]);
    }
}

The implementation contains all the database-specific code. SQL queries, parameter binding, and result hydration happen here. The rest of the application never touches these details.

Using the Repository in Business Logic

Here is how the business logic layer uses the repository. The service class accepts the interface type, not the concrete implementation. This allows any repository implementation to be injected.

class BookingService
{
    private BookingRepositoryInterface $repository;

    public function __construct(BookingRepositoryInterface $repository)
    {
        $this->repository = $repository;
    }

    public function getUpcomingBookingsForCustomer(int $customerId): array
    {
        $today = new \DateTime('today');
        $bookings = $this->repository->findByCustomer($customerId);

        return array_filter($bookings, function (Booking $booking) use ($today) {
            return $booking->getDate() >= $today;
        });
    }

    public function calculateTotalRevenue(array $bookings): float
    {
        return array_reduce($bookings, function (float $total, Booking $booking) {
            return $total + $booking->getPrice();
        }, 0.0);
    }

    public function createBooking(int $customerId, string $service, float $price, \DateTime $date): Booking
    {
        $booking = new Booking(
            null,
            $customerId,
            $date,
            $service,
            $price
        );

        $saved = $this->repository->save($booking);

        if (!$saved) {
            throw new \RuntimeException('Failed to save booking');
        }

        return $booking;
    }
}

The BookingService class focuses entirely on business rules. It does not contain any SQL, any database connection logic, and does not know what technology stores the data. This makes the code easier to understand, test, and modify.

When the Repository Pattern Makes Sense

The repository pattern is not always necessary. For simple scripts, small utilities, and projects with straightforward data access, the added abstraction can be unnecessary complexity. Understanding when to use it helps avoid over-engineering.

Suitable Situations

  • Applications with complex business logic: When business rules involve multiple steps, conditional logic, or calculations that need to be tested independently of data storage.
  • Projects that might change data sources: When there is a realistic possibility of switching databases, adding API integrations, or adding caching layers in the future.
  • Applications that need thorough testing: When unit tests and integration tests need to run without external dependencies like databases.
  • Long-lived applications: Projects that will be maintained and extended over years benefit more from clean separation than throwaway scripts.

Situations Where It May Be Overkill

  • Simple CRUD applications: When the application mostly creates, reads, updates, and deletes records without complex business logic between operations.
  • Small scripts and prototypes: When speed of initial development matters more than long-term maintainability.
  • Single-developer projects with short lifespans: When the codebase will not be maintained by others or extended significantly.

Common Mistakes When Implementing the Repository Pattern

Like any pattern, the repository pattern can be implemented poorly. Understanding common mistakes helps avoid them.

Leaking Database Logic into the Business Layer

The repository should return domain objects or plain data structures, not raw database results. If your service layer contains SQL fragments or database column names, the separation is not working correctly. All database-specific details should stay inside the repository implementation.

Creating One Repository Per Table

Beginners sometimes map repositories directly to database tables. If you have a users table, you create a UserRepository. If you have a bookings table, you create a BookingRepository. This approach works for simple cases but can become limiting.

Business needs often span multiple tables. A booking might involve data from the customers table, the services table, and the bookings table. A repository should represent a data domain, not necessarily a single table. The relationship between business processes and data structures shapes how repositories should be designed.

Over-Abstraction

Creating repository interfaces for every single data access class can add complexity without benefit. If you are unlikely to switch data sources for a particular entity, the interface may be unnecessary overhead. Focus abstraction where flexibility has real value.

Connecting the Repository Pattern to Broader Application Design

The repository pattern works well alongside other design approaches. It fits naturally into service-oriented architectures, domain-driven design, and layered application structures.

When building custom booking systems, the repository pattern helps keep the business logic clean and testable. As the system grows to handle multiple services, customer tiers, and pricing rules, having data access separated from business rules makes the codebase easier to manage.

The pattern also supports better payment processing integration. When payment handling is separate from booking data storage, you can switch payment providers or add new ones without touching the booking logic. This kind of separation matters when comparing payment processing options for service businesses.

Setting Up Dependency Injection

The repository pattern relies on dependency injection to work properly. The business logic layer receives repository instances through its constructor, rather than creating them directly. This allows the implementation to be swapped without changing the business logic.

// Creating the database connection
$dsn = 'mysql:host=localhost;dbname=booking_system';
$username = 'db_user';
$password = 'db_password';
$pdo = new \PDO($dsn, $username, $password);

// Creating the repository with the connection
$repository = new MySqlBookingRepository($pdo);

// Injecting the repository into the service
$bookingService = new BookingService($repository);

// Using the service
$bookings = $bookingService->getUpcomingBookingsForCustomer(42);

For larger applications, a dependency injection container manages these relationships. Frameworks like Symfony and Laravel have built-in containers that handle dependency injection automatically.

Switching Implementations

The real benefit of this approach becomes clear when you need to switch implementations. Suppose you want to add caching to reduce database load. You create a cached repository that wraps the MySQL implementation.

class CachedBookingRepository implements BookingRepositoryInterface
{
    private BookingRepositoryInterface $innerRepository;
    private CacheInterface $cache;

    public function __construct(
        BookingRepositoryInterface $innerRepository,
        CacheInterface $cache
    ) {
        $this->innerRepository = $innerRepository;
        $this->cache = $cache;
    }

    public function findById(int $id): ?Booking
    {
        $key = "booking_{$id}";
        $cached = $this->cache->get($key);

        if ($cached !== null) {
            return $cached;
        }

        $booking = $this->innerRepository->findById($id);
        $this->cache->set($key, $booking, 3600);

        return $booking;
    }

    public function findByCustomer(int $customerId): array
    {
        return $this->innerRepository->findByCustomer($customerId);
    }

    public function findByDateRange(\DateTime $start, \DateTime $end): array
    {
        return $this->innerRepository->findByDateRange($start, $end);
    }

    public function save(Booking $booking): bool
    {
        $result = $this->innerRepository->save($booking);
        $this->cache->invalidate("booking_{$booking->getId()}");
        return $result;
    }

    public function delete(int $id): bool
    {
        $result = $this->innerRepository->delete($id);
        $this->cache->invalidate("booking_{$id}");
        return $result;
    }
}

The service layer does not change at all. You inject the cached repository instead of the MySQL repository, and the caching happens transparently. This kind of flexibility is difficult to achieve without the abstraction layer.

Getting Started with Repository Pattern Implementation

If you are working on a PHP application where data access logic is becoming difficult to manage, introducing the repository pattern can help. Start by identifying the parts of your code where database calls are mixed with business logic. Create repository interfaces for those areas, then extract the data access code into implementations.

You do not need to refactor everything at once. Begin with the most frequently used data access code, or the code that most needs to be testable. As you become comfortable with the pattern, you can extend it to other parts of the application.

If you are building a new application from scratch, designing with repositories in mind from the start makes the pattern easier to apply consistently. Even if you are working with an existing codebase, gradual refactoring toward repository-based data access can improve maintainability without requiring a complete rewrite.

Frequently Asked Questions

Is the repository pattern only useful for large applications?
Not exclusively, but it provides the most value in larger applications where data access logic is used across multiple parts of the codebase. For small scripts or simple CRUD applications, the added abstraction may not justify the extra code. The pattern becomes more valuable as the application grows and the cost of tangled data access increases.
Can I use the repository pattern with an ORM like Eloquent?
Yes. You can wrap an ORM behind a repository interface. This gives you the testability and flexibility benefits while still using an ORM for the actual database operations. The repository interface keeps your business logic independent of the ORM, so you can switch to a different ORM or raw SQL without changing the service layer.
How does the repository pattern affect performance?
The pattern itself adds minimal overhead. The abstraction layer adds a small number of method calls, which is negligible for most applications. The performance impact comes from the choice of repository implementation. A poorly optimised SQL query inside the repository will perform poorly regardless of the pattern. A well-optimised query inside the repository will perform well.
Should every data model have its own repository?
Not necessarily. Repository design should follow the needs of your business logic, not the structure of your database. If two database tables are always accessed together and represent a single business concept, a single repository for that concept may be more appropriate than separate repositories for each table. Design repositories around domain concepts rather than database tables.
Does the repository pattern work with PHP frameworks like Laravel?
Yes. Laravel's service container and dependency injection system support the repository pattern well. You can bind repository interfaces to implementations in a service provider, and Laravel will automatically inject the correct implementation wherever the interface is type-hinted. This approach is common in larger Laravel applications.