订阅制应用应选择 Sylius、Sonata 还是 custom 方案?
结论
更合适的路线是:以 Sylius 作为电商基础,在此之上开发订阅、租户隔离和商家建站模块。
Sonata 不适合作为核心电商框架。它主要用于构建 Symfony 后台管理界面,虽然能加快后台开发,却不能直接解决购物车、订单、商品、促销、库存和支付等完整的电商流程。
也没有必要从零开发整套系统。订阅制和多商家 SaaS 需要大量定制,但商品、订单、结算、税费和促销等通用能力仍可复用。建议按以下边界拆分:
- Sylius 负责标准电商能力;
- 自研 Subscription 模块负责周期计划和续费;
- 自研 Tenant/Merchant 模块负责商家隔离;
- 支付服务商负责保存支付方式和执行自动扣款;
- Sonata 只在需要时辅助开发个别后台页面。
需要注意的是,Sylius 并不是开箱即用的订阅电商 SaaS。选择它,意味着在现有电商模型上扩展,而不是安装一个插件就能获得类似 Cratejoy 的完整功能。
为什么优先选择 Sylius
Sylius 的优势不是直接提供订阅功能,而是已经定义了常见的电商对象和流程,包括:
- Product、ProductVariant 和 Channel;
- Cart、Order 和 OrderItem;
- Customer、Address;
- Payment、Shipment;
- Promotion、Taxation;
- Checkout 状态流转;
- Symfony 服务容器、事件和状态机扩展机制。
订阅商品每月配送一次,每个周期最终仍要生成普通订单、付款记录和发货单。因此,可以把”订阅”设计成订单生成之前的调度层,继续复用现有电商流程:
Subscription
↓ 到达续费日期
创建普通 Order
↓
自动扣款
↓
创建 Shipment
↓
进入现有履约流程
这样,一次性购买和订阅购买就能共用订单、支付、税费、库存和物流系统。
Sonata 为什么不适合作为核心方案
Sonata Admin 可以根据 Doctrine 实体快速生成后台 CRUD 页面。运营人员查看订阅、暂停订阅或修改下次扣款日期时,它能减少一部分后台开发工作。
但 Sonata 本身没有完整的电商领域模型,也不会自动提供以下能力:
- 购物车及结算;
- 订单生命周期;
- 库存预占;
- 促销计算;
- 支付和退款;
- 周期扣款;
- 周期发货。
如果以 Sonata 为基础,核心业务模型几乎都要自行设计。做出来的系统更像是”自研电商系统加后台生成器”,无法真正复用成熟的电商能力。
Sonata 可以作为辅助工具,但不应成为订阅电商架构选型的主要依据。
推荐的领域模型
不要只给 Product 增加一个 isSubscription 字段,再按月复制历史订单。订阅是一项长期合同,需要有独立的状态和数据快照。
一个基础的订阅模型通常至少包括:
<?php
final class Subscription
{
private string $id;
private string $tenantId;
private string $customerId;
private string $productVariantId;
private int $quantity;
private string $currencyCode;
private int $unitPrice;
private string $intervalUnit = 'month';
private int $intervalCount = 1;
private string $status = 'active';
private \DateTimeImmutable $nextBillingAt;
private string $paymentMethodReference;
private string $shippingAddressSnapshot;
}
是否为 unitPrice、币种和收货地址等信息保存快照,要由业务规则决定。例如:
- 商品涨价后,已有订阅是否同步涨价;
- 用户修改默认地址后,原有订阅是否同步修改;
- 优惠只对首单有效,还是对每一期都有效;
- 运费和税费是在订阅创建时确定,还是在续费时重新计算。
Product 或初始 Order 无法完整表达这些规则。
如果一个订阅可以包含多个商品,建议再拆出 SubscriptionItem:
<?php
final class SubscriptionItem
{
private string $productVariantId;
private int $quantity;
private int $unitPrice;
}
多商家 SaaS 的关键设计
多商家设计不能只在用户表中增加一个 merchant_id。至少要明确以下边界:
- 商品属于哪个商家;
- 订单和订阅属于哪个商家;
- 商家的客户数据能否跨店共享;
- 域名、主题、语言和币种如何配置;
- 每个商家使用哪个支付账户;
- 平台如何向商家收费;
- 商家之间是否共享库存和物流;
- 超级管理员能否跨租户查询数据。
可以为核心业务对象增加明确的租户标识:
<?php
interface TenantAwareInterface
{
public function getTenantId(): string;
}
<?php
final class TenantContext
{
public function __construct(
private readonly string $tenantId,
) {
}
public function tenantId(): string
{
return $this->tenantId;
}
}
仅增加 tenant_id 并不能完成租户隔离。Repository、权限校验、消息队列任务、缓存键、文件路径和日志上下文都要包含租户范围。后台查询尤其不能依靠开发人员手动添加条件,最好使用统一的 Repository、Doctrine Filter 或应用层策略强制隔离。
Sylius 的 Channel 可以保存一部分店铺配置,但能否把 Channel 直接作为完整租户,要看项目的隔离要求。如果每个商家都有独立员工、支付账户、域名和数据权限,通常还需要单独设计 Tenant 或 Merchant 模型,不能只依赖 Channel。
订阅续费的实现方式
续费任务必须具备幂等性,防止任务重试时为同一期订阅生成两张订单或重复扣款。
可以使用”订阅 ID + 计费周期”生成唯一的续费键:
<?php
final class RenewSubscription
{
public function __construct(
public readonly string $subscriptionId,
public readonly \DateTimeImmutable $periodStart,
) {
}
public function idempotencyKey(): string
{
return sprintf(
'subscription:%s:%s',
$this->subscriptionId,
$this->periodStart->format('Y-m-d')
);
}
}
续费服务可以按以下方式划分职责:
<?php
final class SubscriptionRenewalService
{
public function renew(
Subscription $subscription,
\DateTimeImmutable $periodStart,
): void {
if (!$subscription->isActive()) {
return;
}
$renewalKey = sprintf(
'subscription:%s:%s',
$subscription->getId(),
$periodStart->format('Y-m-d')
);
if ($this->renewalRepository->exists($renewalKey)) {
return;
}
$order = $this->orderFactory->createForSubscription($subscription);
$this->paymentGateway->charge(
paymentMethodReference: $subscription->getPaymentMethodReference(),
amount: $order->getTotal(),
currency: $order->getCurrencyCode(),
idempotencyKey: $renewalKey,
);
$this->renewalRepository->record($renewalKey, $order->getId());
$subscription->scheduleNextBillingDate();
$this->entityManager->flush();
}
}
这只是架构示例,并不对应某个 Sylius 或支付 SDK 的固定 API。实际项目还要处理数据库锁、事务边界、异步消息、支付回调和失败重试。
周期扫描和实际续费应当分开处理:
- 定时任务查找已经到期的订阅;
- 为每个订阅投递一条续费消息;
- 消费者锁定订阅并检查幂等键;
- 创建待付款订单;
- 请求支付服务商扣款;
- 根据同步结果或 Webhook 更新付款状态;
- 扣款成功后进入发货流程;
- 扣款失败后进入重试和催付流程。
不要依赖每天执行一次的 Cron 脚本完成所有操作。支付响应超时、进程中断和队列重复投递都可能发生,因此任务必须能够安全重试。
支付方式不能由平台自行保存
自动续费通常依赖支付服务商提供的 token、mandate 或 payment method reference。平台不应保存银行卡号或安全码。
典型流程如下:
- 首次结账时,由支付服务商完成支付方式授权;
- 平台只保存支付方式引用,不保存原始卡片数据;
- 续费时使用该引用发起 off-session payment;
- 通过 Webhook 最终确认支付结果;
- 需要额外认证时,通知客户重新完成授权。
不同国家、支付方式和支付服务商对周期扣款有不同规定。实施前必须确认:
- 是否允许商家各自收款;
- 平台是否需要代收再分账;
- 是否支持 marketplace 或 connected account;
- 自动扣款失败后如何重试;
- 是否需要强客户认证;
- 如何处理退款、拒付和授权取消。
支付账户归属是多商家平台应尽早确定的架构问题,因为它会直接影响订单资金流、平台佣金和合规责任。
建议的开发步骤
1. 先确定业务规则
选择框架前,需要明确订阅是否支持:
- 月付、季付或自定义周期;
- 免费试用;
- 首月优惠;
- 暂停和恢复;
- 跳过一期;
- 更换商品或数量;
- 提前取消;
- 按比例计费;
- 固定发货日;
- 扣款失败重试;
- 商家自定义订阅方案。
如果这些规则尚未确定,框架评估很难深入到具体实现。
2. 用 Sylius 验证标准购买流程
先完成一个最小的普通电商闭环:
创建商品 → 加入购物车 → 结账 → 支付 → 创建发货单
先确认现有订单、支付、库存和物流模型是否符合平台需求,再接入订阅功能。初期不要大范围修改 Sylius 的核心实体。
3. 将订阅开发成独立模块
订阅模块负责:
- 创建订阅;
- 计算下次扣款时间;
- 暂停、恢复和取消;
- 生成续费订单;
- 记录每期续费结果;
- 处理扣款失败;
- 保留合同和价格快照。
普通订单仍由 Sylius 处理,从而减少对框架核心代码的修改。
4. 增加租户边界
确定 Product、Order、Customer 和 Subscription 等对象的租户归属,同时完成:
- 商家员工权限;
- 独立域名解析;
- 店铺配置;
- 支付账户映射;
- 租户级缓存和文件隔离;
- 后台查询隔离;
- 数据导出和删除机制。
5. 最后开发商家建站能力
商家建站本身是一个独立产品,不应和订阅模块混在一起。它通常包括:
- 主题和页面模板;
- 自定义域名;
- 导航和内容管理;
- 商品展示配置;
- 结账页品牌配置;
- SEO 元数据;
- 店铺发布和预览。
Sylius 可以提供商品与结账后端,但不会自动成为完整的网站生成器。
什么时候应该选择完整 custom solution
以下情况可能适合从核心领域开始自研:
- 业务不是传统商品订单,而是复杂合同、额度或服务履约;
- 每期商品组合由算法动态生成,普通订单模型难以表达;
- 平台需要高度特殊的结算、分账或供应链流程;
- 团队已经拥有成熟的电商基础设施;
- 评估后发现 Sylius 的大部分核心流程都需要替换。
即使选择 custom,也不代表所有功能都要从零编写。Symfony、Doctrine、支付 SDK、消息队列、搜索和身份认证等成熟组件仍然可以复用。
如果大部分需求仍然是商品、购物车、订单、付款和配送,只因增加了”每月续费”就完全自研,通常会低估促销、税费、退款、库存、支付异常和后台运营的工作量。
需要特别注意的问题
不要把订阅等同于重复订单
订阅是合同,订单是某一期的履约记录。一个订阅应对应多张订单,而不是反复修改同一张订单。
不要依赖当前商品数据重建历史
商品可能下架、改名或涨价。历史续费和财务记录必须保留当时的数据快照。
为所有外部调用设计幂等键
支付、建单、发货和通知都可能因超时而重试。缺少幂等机制,很容易造成重复扣款或重复发货。
尽早确定库存策略
要明确库存是在整个订阅周期内预留,还是每次续费时重新检查。如果续费成功后库存不足,还要规定退款、延期或替代商品的处理流程。
不要只在界面权限中处理租户隔离
API、异步任务、数据库查询、导出、缓存和对象存储都必须执行相同的租户隔离规则。
控制对框架核心代码的修改
优先使用服务装饰、事件监听、状态机扩展和独立领域模块接入订阅逻辑。直接修改依赖包源码会迅速增加后续升级成本。
最终建议
对于多商家、实物周期配送的 SaaS,更合理的做法是明确各部分的复用和自研边界:
- 使用 Sylius 承担通用电商流程;
- 自研订阅生命周期和续费调度;
- 自研 Tenant/Merchant 层,保证商家隔离;
- 使用支付服务商提供的周期扣款和 marketplace 能力;
- Sonata 只在确有后台 CRUD 需求时使用;
- 先通过小规模原型验证订单、续费、支付和租户隔离,再决定是否继续扩展 Sylius。
如果原型验证表明 Sylius 的订单和结账模型可以复用,就没有必要承担完整 custom solution 的开发成本。只有当核心业务流程与标准电商模型存在根本冲突时,才适合从头构建核心系统。
备注:内容仅供参考。
