PAYATHON 2026

订阅制应用应选择 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。实际项目还要处理数据库锁、事务边界、异步消息、支付回调和失败重试。

周期扫描和实际续费应当分开处理:

  1. 定时任务查找已经到期的订阅;
  2. 为每个订阅投递一条续费消息;
  3. 消费者锁定订阅并检查幂等键;
  4. 创建待付款订单;
  5. 请求支付服务商扣款;
  6. 根据同步结果或 Webhook 更新付款状态;
  7. 扣款成功后进入发货流程;
  8. 扣款失败后进入重试和催付流程。

不要依赖每天执行一次的 Cron 脚本完成所有操作。支付响应超时、进程中断和队列重复投递都可能发生,因此任务必须能够安全重试。

支付方式不能由平台自行保存

自动续费通常依赖支付服务商提供的 token、mandate 或 payment method reference。平台不应保存银行卡号或安全码。

典型流程如下:

  1. 首次结账时,由支付服务商完成支付方式授权;
  2. 平台只保存支付方式引用,不保存原始卡片数据;
  3. 续费时使用该引用发起 off-session payment;
  4. 通过 Webhook 最终确认支付结果;
  5. 需要额外认证时,通知客户重新完成授权。

不同国家、支付方式和支付服务商对周期扣款有不同规定。实施前必须确认:

  • 是否允许商家各自收款;
  • 平台是否需要代收再分账;
  • 是否支持 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 的开发成本。只有当核心业务流程与标准电商模型存在根本冲突时,才适合从头构建核心系统。

备注:内容仅供参考。