Skip to content

Subscription Workflow

Newsletter owns subscriber state, consent evidence, provider sync attempts, and public confirmation/unsubscribe routes. Form Builder can feed it, but subscription rules stay in Newsletter actions.

  1. A form submission or package action calls SubscribeFromFormSubmissionAction or UpsertSubscriberAction.
  2. The subscriber is created or updated with a SubscriberStatus.
  3. Consent evidence is written when the source supplied a consent field.
  4. Tags are applied through ApplyNewsletterTagsAction.
  5. If the subscriber is fully subscribed, QueueProviderSyncAction queues provider sync.
  6. If double opt-in is required, RequestDoubleOptInAction creates a public confirm token.

Use ConfirmSubscriberAction for Capell-owned double opt-in links and UnsubscribeSubscriberAction for public unsubscribe links.

SubscribeFromFormSubmissionAction listens for form submissions and looks up an active FormMapping for the form’s site. It matches by form_id or form_handle.

Required mapping fields:

Mapping fieldPurpose
email_fieldPayload key containing the email address.
consent_fieldOptional payload key that must evaluate to true before consent evidence is recorded.
first_name_field / last_name_fieldOptional profile fields.
fixed_tag_idsNewsletter tags applied to every matching subscriber.
field_tag_mappingsPayload value to tag mappings.
requires_double_opt_inControls pending versus subscribed status.
confirmation_modecapell_owned or provider-owned confirmation.
use Capell\Newsletter\Actions\UpsertSubscriberAction;
use Capell\Newsletter\Data\SubscriberData;
use Capell\Newsletter\Enums\SubscriberStatus;
$subscriber = UpsertSubscriberAction::run(new SubscriberData(
siteId: $site->getKey(),
status: SubscriberStatus::Pending,
));

Call RequestDoubleOptInAction after this when the subscriber must confirm through Capell before provider sync.

use Capell\Newsletter\Actions\ApplyNewsletterTagsAction;
ApplyNewsletterTagsAction::run($subscriber, [$tagId], replace: false);

The action only accepts tags whose type matches capell-newsletter.newsletter_tag_type, which defaults to newsletter.

Provider adapters implement NewsletterProviderAdapter:

use Capell\Newsletter\Contracts\NewsletterProviderAdapter;
use Capell\Newsletter\Data\ProviderAudienceData;
use Capell\Newsletter\Data\ProviderSubscriberData;
use Capell\Newsletter\Data\ProviderSyncResultData;
use Capell\Newsletter\Data\ProviderWebhookEventData;
use Capell\Newsletter\Models\ProviderAudience;
use Capell\Newsletter\Models\ProviderConnection;
use Illuminate\Http\Request;
final class DemoNewsletterAdapter implements NewsletterProviderAdapter
{
public function supportsOAuth(): bool
{
return false;
}
public function supportsProviderOwnedConfirmation(): bool
{
return false;
}
/** @return array<int, ProviderAudienceData> */
public function listAudiences(ProviderConnection $connection): array
{
return [];
}
public function syncSubscriber(
ProviderConnection $connection,
ProviderAudience $audience,
ProviderSubscriberData $subscriber,
): ProviderSyncResultData {
return new ProviderSyncResultData(successful: true);
}
public function verifyWebhook(ProviderConnection $connection, Request $request): bool
{
return true;
}
public function normalizeWebhook(ProviderConnection $connection, Request $request): ?ProviderWebhookEventData
{
return null;
}
}

Keep provider failures in ProviderSyncResultData where possible. Throwing from an adapter should mean the attempt could not complete, not that the provider rejected a subscriber.

NewsletterAudienceRegistry accepts NewsletterAudienceProvider implementations. The built-in SegmentAudienceProvider returns active Segment records for a site.

NewsletterSegmentProvider exists for segment-specific subscriber queries:

use Capell\Newsletter\Contracts\NewsletterSegmentProvider;
use Capell\Newsletter\Models\Segment;
use Capell\Newsletter\Models\Subscriber;
use Illuminate\Database\Eloquent\Builder;
final class RecentPurchaserSegmentProvider implements NewsletterSegmentProvider
{
/** @return Builder<Subscriber> */
public function querySubscribers(Segment $segment): Builder
{
return Subscriber::query()->where('site_id', $segment->site_id);
}
}
KeyPurpose
capell-newsletter.tables.*Table names for subscribers, consent events, provider records, segments, form mappings, and imports.
capell-newsletter.double_opt_in.enabled_by_defaultDefault double opt-in state for mappings and manual flows.
capell-newsletter.double_opt_in.default_confirmation_modeDefault confirmation owner.
capell-newsletter.double_opt_in.token_expiry_hoursConfirm token lifetime.
capell-newsletter.resubscribe_policyDefault policy for resubscribing previously known subscribers.
capell-newsletter.newsletter_tag_typeTag type accepted by ApplyNewsletterTagsAction.
capell-newsletter.sync.queueQueue for provider sync jobs.
capell-newsletter.sync.retry_minutesRetry schedule used for provider sync attempts.
RoutePurpose
GET /newsletter/confirm/{token}Confirms a Capell-owned double opt-in token.
GET /newsletter/unsubscribe/{token}Unsubscribes by public token.
POST /newsletter/providers/{providerConnection}/webhookReceives provider webhooks and normalizes them through the adapter.
newsletter:sync-retry-due {--limit=}

Use this to requeue due provider sync attempts. It should not be used as a replacement for queue workers.