For the last few months, we’ve been working on the MVP for a project similar to the Airbnb booking engine. Even though we were building an MVP, we still wanted to use a top-notch stack, amazingly fast application, and we wanted to reach the market as soon as possible. On the frontend, we are using Next.js with Vercel as hosting, and on the backend side, we are using Sylius as a headless e-commerce solution with our custom dedicated servers.
Was this a good idea, is it even possible? Let’s find out.
Is Sylius ready to be a booking engine?
At the moment, it is probably not ready to be a booking engine, at least not out of the box. But booking engines can be quite similar to e-commerce, and the flexibility of Sylius offers you a way to use it for many different purposes.
Sylius is using ApiPlatform (still a work in progress and in the experimental phase) to provide a headless system. Since our team in Locastic are experts in the entire stack (Symfony, Sylius, ApiPlatform) and since we calculated that we will save some time in getting to the market faster with Sylius – we wanted to give it a try.
Please note that I am not saying that this is the best way for building the booking engine, if you have a huge and complex project, maybe a better solution is to build an entire engine from the scratch or use existing solutions. Also, in this situation, you need to know Sylius and ApiPlatform in the depth, almost every detail and you will have a lot of overhead (features from Sylius that you are not using).
Modeling accommodations a.k.a. SyliusProduct
For this MVP project, the most complex part was modelling Products. Our products are actually Accommodations. Even basic accommodation has:
different attributes (for example pets allowed, has a pool, smoking allowed, free parking on premises, bbq and etc). In general, any amenities are properties in our case and we will use this for searching and filtering accommodations
a different price and availability per date
number of minimal days for booking (min stay)
number of guests, with different settings for children and infants
shipping is not required
Using Sylius Product Attributes
When we are talking about product attributes, we are usually dealing with them in two ways. Adding them in the code (extending Product or ProductVariant entities, forms and etc.), or using built-in Sylius properties. In general, the first way requires a lot of coding but you will maybe gain some performance in searching and managing queries later, so if you have a few properties that are always required, this could be a way to go.
The second way is more flexible and since we already have 100+ Attributes, it was a logical choice for us. Also, we don’t want to spend a few hours/days each time to introduce a new Attribute when this can be added by the Client through the Sylius administration. Each new Attribute that is added is exposed via API to the frontend and it is shown in the filters list as part of the search form.
Since we have a lot of Attributes we don’t want to bother users to search and add them one by one to the Product (current Sylius UX in administration), so we changed the ProductFactory service to add all of the Attributes to each Product/Accommodation. Here is the source code:
<?php
declare(strict_types=1);
namespace App\Factory;
use App\Entity\Product\Product;
use App\Entity\Product\ProductAttributeValue;
use App\Entity\Product\ProductOption;
use App\Repository\Product\ProductOptionRepository;
use Sylius\Component\Product\Factory\ProductFactoryInterface;
use Sylius\Component\Product\Model\ProductInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Webmozart\Assert\Assert;
final class ProductFactory implements ProductFactoryInterface
{
private ProductFactoryInterface $decoratedFactory;
private ProductOptionRepository $productOptionRepository;
private RepositoryInterface $productAttributeRepository;
private FactoryInterface $productAttributeValueFactory;
public function __construct(
ProductFactoryInterface $factory,
ProductOptionRepository $productOptionRepository,
RepositoryInterface $productAttributeRepository,
FactoryInterface $productAttributeValueFactory
) {
$this->decoratedFactory = $factory;
$this->productOptionRepository = $productOptionRepository;
$this->productAttributeRepository = $productAttributeRepository;
$this->productAttributeValueFactory = $productAttributeValueFactory;
}
public function createNew(): ProductInterface
{
/** @var Product $product */
$product = $this->decoratedFactory->createNew();
$optionDate = $this->productOptionRepository->findOneByCode('date');
Assert::isInstanceOf($optionDate, ProductOption::class);
$product->addOption($optionDate);
$attributes = $this->productAttributeRepository->findAll();
foreach ($attributes as $attribute) {
/** @var ProductAttributeValue $attributeValue */
$attributeValue = $this->productAttributeValueFactory->createNew();
$attributeValue->setAttribute($attribute);
$product->addAttribute($attributeValue);
}
return $product;
}
public function createWithVariant(): ProductInterface
{
return $this->decoratedFactory->createWithVariant();
}
}
Since most of these Attributes are true/false, all the user needs to do is to mark the true ones.
Sylius ProductVariants to the rescue
Let’s think a little bit about ProductVariants. In their core definition, they are the same Product but with some different variations, as the name says. In Sylius we can generate ProductVarints from the ProductOption, and out of the box we have the possibility to add different price, different ProductOptionValue, different stock (availability). So it is a perfect match for our Accommodation (Product) which will have different price, availability for each date (ProductVariant).
If you paid attention above to the ProductFactory service, you noticed this line:
$this->productOptionRepository->findOneByCode('date');
So, we created a ProductOption with the code/name ‘date’. Then we assign all the possible values for this Option for example all the dates from today to the +2years (note: don’t do this manually, also you will need to create CronJob that will remove the date in the past and add new dates for example every day). From this option, we can now generate all the required variants.
Shipping is not required, tracking is required
Since we are not shipping any of our products, we have a requirement to mark isShippingRequired as false. We want to be sure that this requirement is always fulfilled, and we don’t want to bother our client with this, so we simply override the method in the ProductVariant.
// none of items requires shipping, always false
public function isShippingRequired(): bool
{
return false;
}
A similar thing we did with isTracked method, just we are returning true there. In general, Sylius Admin is not very user friendly for this type of project, since we are managing literally thousands of ProductVariants, so in our case, we removed the entire part of Sylius admin where ProductVariatns are managed, and put all of this in the simple calendar, where user can see price, availability, reservations, can close the dates and etc. Actually, we build a more native and domain friendly UX for this type of project.
With isShippingRequired === false, we are also removing a step with delivery from our checkout process, since we are not shipping anything.
Sylius cart/order modifications for a booking system
I already mentioned how we removed the shipping step from the checkout, in general, if you need any other adjustment you will need to deal with Sylius checkout workflow, also if you are using the APIs the things are slightly different.
What we also did here is allowing only one product (there is almost a 0% chance that someone will book two accommodations at the same time. Since the booking domain is slightly different, we wanted to adjust adding to the cart to the more natural flow. So instead of sending an array of ProductVariant ids, we are sending Product Id and reservation starting and add date. Bellow is part of the code of our AddItemsToCartProcessor that is used inside CommandHander.
<?php
declare(strict_types=1);
namespace App\Order\Processor;
use App\Entity\Order\Order;
use App\Entity\Order\OrderItem;
use App\Entity\Product\Product;
use App\Entity\Product\ProductVariant;
use App\Message\Order\BaseAddItemsToOrderMessage;
use App\Repository\Product\ProductVariantRepository;
use Sylius\Component\Order\Modifier\OrderItemQuantityModifierInterface;
use Sylius\Component\Order\Modifier\OrderModifierInterface;
use Sylius\Component\Order\Processor\OrderProcessorInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
class AddItemsToCartProcessor
{
// ...
public function process(Order $cart, Product $product, BaseAddItemsToOrderMessage $message): Order
{
$cart->clearItems();
$productVariants = $this->productVariantRepository->findAvailableByProductCodeBetweenDates(
$message->getProductCode(),
$message->getDateFrom(),
$message->getDateTo()
);
if (empty($productVariants)) {
return $cart;
}
foreach ($productVariants as $productVariant) {
/** @var OrderItem $cartItem */
$cartItem = $this->orderItemFactory->createNew();
/** @var ProductVariant $productVariant */
$cartItem->setVariant($productVariant);
$cartItem->setDate($productVariant->getDate());
$cartItem->setVariantName($productVariant->getName());
$cartItem->setProductName($productVariant->getProduct()->getName());
$this->orderItemQuantityModifier->modify($cartItem, 1);
$this->orderModifier->addToOrder($cart, $cartItem);
}
// ... some custom logic ...
$this->orderProcessor->process($cart);
return $cart;
}
}
Don’t forget to create a custom API endpoint, with a custom message and message handler.
Booking (SyliusOrder) validation
You can notice above that we are not validating our request anywhere, and we should do that. We should validate first basic things, like data received from the application. For basic validation of data, we are using SymfonyValidor, with build validation:
App\Message\Order\AddItemsToOrderMessage:
constraints:
- App\Validator\ValidReservationRequest: ~
properties:
productCode:
- NotNull: ~
- NotBlank: ~
dateFrom:
- Type: \DateTimeInterface
- GreaterThanOrEqual: today
dateTo:
- Type: \DateTimeInterface
- GreaterThan:
propertyPath: dateFrom
adults:
- Type: integer
- PositiveOrZero: ~
children:
- Type: integer
- PositiveOrZero: ~
infants:
- Type: integer
- PositiveOrZero: ~
Probably you already noticed something not standard-out-of-the box validation here, and yes
constraints:
- App\Validator\ValidReservationRequest: ~
is our most important and custom validation. Here we are validating things as product exist, minimal stay, number of guests, number of infants, availability and etc. Of course, this validation can be even more complex, this is depending on your requirements, but this is just an example of what you should have in mind.
<code><?php
declare(strict_types=1);
namespace App\Validator;
use App\Entity\Product\Product;
use App\Message\Order\BaseAddItemsToOrderMessage;
use App\Repository\Product\ProductRepository;
use App\Repository\Product\ProductVariantRepository;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
class ValidReservationRequestValidator extends ConstraintValidator
{
private ProductRepository $productRepository;
private ProductVariantRepository $productVariantRepository;
public function __construct(
ProductRepository $productRepository,
ProductVariantRepository $productVariantRepository
) {
$this->productRepository = $productRepository;
$this->productVariantRepository = $productVariantRepository;
}
public function validate($addItemsToOrderMessage, Constraint $constraint): void
{
if (!$constraint instanceof ValidReservationRequest) {
throw new UnexpectedTypeException($constraint, ValidReservationRequest::class);
}
/** @var BaseAddItemsToOrderMessage $addItemsToOrderMessage */
Assert::isInstanceOf($addItemsToOrderMessage, BaseAddItemsToOrderMessage::class);
$product = $this->productRepository->findOneByCode($addItemsToOrderMessage->getProductCode());
// check if product exists
if (!$product instanceof Product) {
$this->context->buildViolation($constraint->messageProductNotFound)
->addViolation();
return;
}
// check minimal stay requirements
if ($product->getMinStay() > $addItemsToOrderMessage->getStayLength()) {
$this->context->buildViolation($constraint->messageMinimalStay)
->setParameter('{{ minStay }}', (string) $product->getMinStay())
->addViolation();
return;
}
// check number of guests (noOfGuests = adults + children)
if ($product->getNumberOfGuests() < $addItemsToOrderMessage->getNumberOfGuests()) {
$this->context->buildViolation($constraint->messageNumberOfGuests)
->setParameter('{{ numberOfGuests }}', (string) $product->getNumberOfGuests())
->addViolation();
return;
}
// check number if infants
if ($product->getNumberOfInfants() < $addItemsToOrderMessage->getInfants()) {
// infants are not allowed
if ($product->getNumberOfInfants() === 0) {
$this->context->buildViolation($constraint->messageInfantsNotAllowed)
->addViolation();
return;
}
$this->context->buildViolation($constraint->messageNumberOfInfants)
->setParameter('{{ numberOfInfants }}', (string) $product->getNumberOfGuests())
->addViolation();
return;
}
// ensure dates are sent without defined time
if ($addItemsToOrderMessage->getDateFrom() != $addItemsToOrderMessage->getDateFrom()->setTime(0, 0)) {
$this->context->buildViolation($constraint->messageTimeDefined)
->addViolation();
return;
}
if ($addItemsToOrderMessage->getDateTo() != $addItemsToOrderMessage->getDateTo()->setTime(0, 0)) {
$this->context->buildViolation($constraint->messageTimeDefined)
->addViolation();
return;
}
if ($addItemsToOrderMessage->getStayLength() !== $this->getNumberOfAvailableProductVariants($addItemsToOrderMessage)) {
$this->context->buildViolation($constraint->messageDatesAreNotAvailable)
->addViolation();
return;
}
}
private function getNumberOfAvailableProductVariants(BaseAddItemsToOrderMessage $addItemsToOrderMessage): int
{
return count(
$this->productVariantRepository->findAvailableByProductCodeBetweenDates(
$addItemsToOrderMessage->getProductCode(),
$addItemsToOrderMessage->getDateFrom(),
$addItemsToOrderMessage->getDateTo()
)
);
}
}
So, can Sylius be a booking engine?
Although it would take ages to describe the custom adaptations we did inside this project, it is possible!
Product and ProductVariants can work perfectly to build availability and pricing calendar for accommodations, we can easily remove shipping and add tracking to our ProductVariants, also it is very easy to add custom validation. Yes, you are getting a lot of overhead from Sylius also, the Sylius Admin UX is not the most friendly one for this type of project and etc. It is up to the team to decide if this approach is good for the project or not – but for us, it’s been working like a charm.
Let us know in the comments what do you think about this approach, or drop us an email if you need help with any type of Sylius/Symfony projects.
Top comments (0)