sylius/customer-bundle
Sylius Customer Bundle provides customer management for Sylius e-commerce apps, including customer models, repositories, managers, address handling, profile forms, and integration with users and orders. It offers reusable services and UI components for creating, editing, and resolving customers.
sylius/customer-bundle is a read-only subtree split of the Sylius monorepo, and it's a Symfony bundle. There is no service provider, no config/customer.php to publish, no Eloquent. So "day-to-day leverage" resolves to one of:
Decide this first, because the first scenario is a five-minute question and the second determines everything you read.
# Wrong
composer require sylius/customer-bundle
# Right — the bundle arrives transitively, version-aligned
composer require sylius/sylius
sylius/order-bundle, sylius/user-bundle, sylius/core-bundle, and sylius/shop-bundle all type-hint Sylius\Component\Customer\Model\CustomerInterface. Pulling the split package directly guarantees a version skew that surfaces as confusing DI errors at boot, not at install time. Dependents: 0 and Stars: 3 are a distribution artefact of the split — adoption is measured through sylius/sylius.
| Path | Why |
|---|---|
Model/Customer.php |
The contract you'll either extend or port. Note email, emailCanonical, firstName, lastName, birthday, gender, phoneNumber, subscribedToNewsletter, group, defaultAddress, addresses, user, createdAt, updatedAt, anonymizedAt. |
Model/CustomerInterface.php |
The seam. Everything downstream depends on this interface, not the class. |
Model/CustomerGroup.php |
Trivial — a code + name. Its value is that it exists as the segmentation hook. |
Resources/config/doctrine/model/Customer.orm.xml |
The actual schema: column lengths, indexes, join tables, cascade rules. Copy this, not the PHP. |
Resources/config/services.xml |
What's actually registered: factory, repository, form types, the resource metadata. |
Form/Type/CustomerType.php, CustomerProfileType.php, CustomerRegistrationType.php |
Where the real validation and field-level behaviour lives. |
Resources/config/validation.xml |
Constraints, including unique email. |
Addresses are not here — Customer::addresses maps to Sylius\Component\Addressing\Model\Address from sylius/addressing-bundle. Expect to read two bundles for one feature.
On Sylius: add a CustomerGroup-driven wholesale price. Create the group via the admin, subscribe a customer to it, write a PriceCalculator that checks $customer->getGroup()?->getCode().
On Laravel: port customers + customer_groups as your first two migrations and a Customer Eloquent model that has a group() belongsTo and a user() hasOne. This is a half-day exercise that saves a week of schema discovery.
The supported extension point is class inheritance plus resource config override, not editing vendor code:
// src/Entity/Customer.php
namespace App\Entity;
use Doctrine\ORM\Mapping as OR:MappedSuperclass;
use Sylius\Component\Customer\Model\Customer as BaseCustomer;
#[ORM\MappedSuperclass]
class Customer extends BaseCustomer
{
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $loyaltyTier = null;
}
# config/packages/_sylius.yaml
sylius_customer:
resources:
customer:
classes:
model: App\Entity\Customer
Then re-run bin/console doctrine:schema:update (or generate a migration). The sylius_customer.resources.customer key is the single knob — factory, repository, form, and controller are all resolved from it via the resource registry.
The repository interface (CustomerRepositoryInterface::findOneByEmail, findByGroup, etc.) is what controllers and other bundles depend on. When you need "customers in this group created since date", add a method to the repository and override the sylius.repository.customer service class rather than putting queries in a controller:
sylius:
repository:
customer:
class: App\Repository\CustomerRepository
Do not override CustomerProfileType wholesale — you break every other bundle that composes it. Use AbstractTypeExtension:
class CustomerProfileTypeExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [CustomerProfileType::class];
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('loyaltyTier', TextType::class, ['required' => false]);
}
}
Tag it form.type_extension. This survives Sylius minor upgrades.
Translate the Doctrine mapping, not the PHP. The migration from Customer.orm.xml:
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('email')->nullable();
$table->string('email_canonical')->nullable()->unique();
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
$table->date('birthday')->nullable();
$table->string('gender')->nullable();
$table->string('phone_number')->nullable();
$table->boolean('subscribed_to_newsletter')->default(false);
$table->foreignId('customer_group_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('user_id')->nullable()->unique()->constrained()->nullOnDelete();
$table->timestamp('anonymized_at')->nullable();
$table->timestamps();
$table->index(['last_name', 'first_name']);
});
Keep three things from the original:
Customer row exists the moment someone checks out as a guest; a User row appears only on registration. Collapsing them into one users table is the mistake that forces a painful migration later when you add SSO.email_canonical as a separate unique column. Lets ops edit email for display (case, formatting) without breaking the identity key.anonymized_at. Not deleted_at. GDPR erasure nulls the PII in place while preserving order history and aggregate reporting.The one piece of behaviour genuinely worth copying. CustomerGroup is a stable, code-identified bucket, designed to be consulted by pricing, tax, and catalog-visibility rules. In Laravel, resolve the effective group once per request into a scoped singleton rather than re-querying it in every price calculation:
$this->app->scoped(EffectiveGroup::class, fn () =>
new EffectiveGroup(auth()->user()?->customer?->customerGroup)
);
Then inject it into your price and discount services. This mirrors how Sylius's CustomerGroup is consumed by promotion rules and pricing.
Sylius creates a Customer with no user at guest checkout, and attaches the ShopUser on registration, merging carts by Customer. If you're porting this, the sequence is:
Customer row, user_id = null.email_canonical, attach user_id, do not create a second Customer.order.customer_id, never order.user_id.The CustomerFactory in the bundle is what makes step 1 cheap; mirror it with a small factory/service class rather than Customer::create() scattered through controllers.
This repository takes no PRs. Issues and patches go to Sylius/Sylius. Any fork becomes a permanent merge burden against every upstream change. Price that in before patching a bug locally.
Addresses live in sylius/addressing-bundle. Searching this repo for Address finds only the defaultAddress relation and the interface. If you're porting the address model (default_billing_address, default_shipping_address pointers, one-to-many address book), you're reading the wrong package.
Never define your own Customer entity against the same table. Two Doctrine mappings for one table produce The table with name 'customers' already exists or silent partial hydration. Extend the MappedSuperclass; if you need a parallel entity, use a different table name and a mapping override.
Unique-email validation is global, not per-channel. The constraint on email predates multi-channel Sylius. If you need per-channel identities in your port, that's a new constraint, not a copy.
Version coupling is non-negotiable. This bundle's PHP and Symfony require constraints are dictated by the Sylius release line. You cannot upgrade it independently, and pinning it manually in composer.json is how you get a composer why-not rabbit hole.
Forms are Symfony Form component only. CustomerProfileType, CustomerRegistrationType, and friends are 300-line FormBuilder trees. Do not try to evaluate them for Laravel reuse — read them as a validation checklist (required fields, birthday format, gender choices) and write FormRequests.
Impersonation and password-reset touch Customer via ShopUser. Any change to the customer/user relation is a security change. Write integration tests around login, impersonation, and reset before and after the change.
Watch for N+1 on the customer graph. $customer->getAddresses(), ->getGroup(), and ->getUser() are each a lazy load. In admin list views and pricing loops in particular, eager-load them. This is the single most common performance complaint in Sylius shops.
anonymized_at is not optional in the EU. Sylius's anonymisation nulls PII but leaves the row. If your port only has soft deletes, erasure requests force you to rewrite rows that orders still reference. Build the column in from day one.
Fixtures for day-to-day dev. If you're on Sylius, sylius_fixtures loads customers via load_customers. Use it — hand-seeding customer groups makes the group-pricing work impossible to test realistically.
Debugging tips (Sylius path):
bin/console debug:container sylius.repository.customer
bin/console debug:container sylius.factory.customer
bin/console doctrine:mapping:info | grep -i customer
bin/console debug:config sylius_customer
The first thing to check on any "customer form doesn't show my field" bug is whether your AbstractTypeExtension is registered — debug:container --tag=form.type_extension lists it.
Extension point summary: model (MappedSuperclass + sylius_customer.resources.*.classes.model), repository (sylius.repository.customer.class), factory (sylius.factory.customer.class), forms (form.type_extension), validation (a validation.xml/constraint in your app bundle, not vendor edits). Everything else is a fork.
How can I help you explore Laravel packages today?