Podcasts Module¶
Audience: maintainers and contributors working on the Podcasts feature — audio hosting, RSS distribution, listen analytics, or admin moderation.
Status: Alpha. The module ships behind the
podcaststenant feature flag (default OFF). All routes and React pages are gated; the accessible-frontend surfaces are also present underaccessible-frontend/views/podcasts*.blade.php.
Overview¶
The Podcasts module lets community members create and publish podcast shows directly inside a tenant. Key workflows:
- Creator — create a show, upload or link episode audio, schedule publication, manage chapters and transcripts.
- Listener — browse and search shows, subscribe for episode notifications, react to and report episodes.
- Admin — moderate shows and episodes (approve/reject/flag), validate RSS feeds, triage episode reports, view listen analytics.
Feature gate¶
The podcasts feature flag on tenants.features controls the entire module. Every API route calls ensurePodcastsFeature() (defined in app/Http/Controllers/Api/Concerns/InteractsWithPodcasts.php), which returns HTTP 403 FEATURE_DISABLED when the flag is absent. The React frontend wraps every podcast route in <FeatureGate feature="podcasts" redirect="/">.
To enable for a tenant:
Or toggle through the admin UI at /admin/tenant-features.
Database schema¶
All tables carry tenant_id for row-level tenant isolation. There are no database-level foreign keys (matching the course/marketplace convention for loosely-coupled modules). All queries scope by tenant_id through Eloquent global scopes on the models.
| Table | Purpose |
|---|---|
podcast_shows |
One row per show; owner_user_id, lifecycle (status, moderation_status, visibility), iTunes/RSS metadata (author_name, owner_email, copyright, funding_url, explicit), counters (episode_count, subscriber_count) |
podcast_episodes |
Episodes; show_id, author_user_id, audio location (audio_url, audio_storage_path, audio_storage_disk, audio_mime, audio_bytes), media pipeline (media_processing_status, media_scan_status), scheduling (scheduled_for, published_at, announced_at), optional transcript / cover_image_url |
podcast_episode_chapters |
Chapter markers per episode (starts_at_seconds, title, url, position) |
podcast_episode_listens |
One row per deduped listen event; privacy-safe hashes of IP, user-agent, and session ID; listened_seconds, completed, client_family, retention_bucket |
podcast_episode_reactions |
Emoji/like reactions (reaction varchar); unique per (tenant_id, episode_id, user_id, reaction) |
podcast_show_subscriptions |
Member subscriptions to shows; notify_new_episodes flag |
podcast_episode_reports |
Member content reports; reason, details, status (open/resolved/dismissed/escalated), reviewed_by, reviewed_at |
Migrations (in order):
database/migrations/2026_06_03_000001_create_podcast_module_tables.php— core tablesdatabase/migrations/2026_06_03_000002_add_distribution_metadata_to_podcasts.php— RSS/iTunes metadata columnsdatabase/migrations/2026_06_03_000003_harden_podcast_media_and_moderation.php— media pipeline columns, subscriptions, reportsdatabase/migrations/2026_06_04_120500_add_announced_at_to_podcast_episodes.php— idempotent announcement guard
Key code locations¶
| Layer | File |
|---|---|
| Service (core logic) | app/Services/PodcastService.php |
| Tenant configuration | app/Services/PodcastConfigurationService.php |
| Member API controller | app/Http/Controllers/Api/PodcastController.php |
| Admin API controller | app/Http/Controllers/Api/AdminPodcastController.php |
| Shared controller concern | app/Http/Controllers/Api/Concerns/InteractsWithPodcasts.php |
| Async media job | app/Jobs/ProcessPodcastEpisodeMedia.php |
| Scheduled release command | app/Console/Commands/ReleaseScheduledPodcastEpisodes.php |
| Models | app/Models/PodcastShow.php, PodcastEpisode.php, PodcastEpisodeChapter.php, PodcastEpisodeListen.php, PodcastEpisodeReaction.php |
| React pages | react-frontend/src/pages/podcasts/ |
| React audio player | react-frontend/src/components/podcasts/PodcastAudioPlayer.tsx |
| React API client | react-frontend/src/lib/api/podcasts.ts |
| React admin panel | react-frontend/src/admin/modules/podcasts/PodcastsAdmin.tsx |
| Accessible-frontend views | accessible-frontend/views/podcasts.blade.php, podcast-detail.blade.php, podcast-episode.blade.php, commerce-podcast-*.blade.php |
API routes¶
Routes are defined in routes/api.php. See that file (or the project OpenAPI reference) for the canonical endpoint list. A summary follows.
Public (no auth required):
| Method | Path | Handler |
|---|---|---|
GET |
/v2/podcasts |
Browse/search shows |
GET |
/v2/podcasts/{showSlug} |
Show detail + episodes |
GET |
/v2/podcasts/{showSlug}/{episodeSlug} |
Single episode |
GET |
/v2/podcasts/{showSlug}/feed.xml |
RSS feed (iTunes-compatible) |
GET |
/v2/podcasts/feed/{tenantId}/{showSlug}.xml |
RSS feed by numeric tenant ID (for aggregator subscriptions) |
GET |
/v2/podcasts/media/{tenantId}/{episodeId}/audio |
Hosted audio stream / redirect |
GET |
/v2/podcasts/transcripts/{tenantId}/{episodeId}.txt |
Episode transcript |
GET |
/v2/podcasts/chapters/{tenantId}/{episodeId}.json |
Chapters (Podcast Namespace JSON) |
POST |
/v2/podcasts/episodes/{episodeId}/listen |
Record a listen event |
Authenticated members (Sanctum):
| Method | Path | Purpose |
|---|---|---|
GET |
/v2/podcasts/mine |
Caller's shows with all episodes |
POST |
/v2/podcasts |
Create a show |
PUT |
/v2/podcasts/{id} |
Update a show |
POST |
/v2/podcasts/{id}/publish |
Publish a show |
POST |
/v2/podcasts/{id}/archive |
Archive a show |
DELETE |
/v2/podcasts/{id} |
Delete a show (cascades to episodes and audio files) |
POST |
/v2/podcasts/{showId}/episodes |
Create an episode (JSON body or multipart/form-data with audio file) |
PUT |
/v2/podcasts/{showId}/episodes/{episodeId} |
Update an episode |
POST |
/v2/podcasts/{showId}/episodes/{episodeId}/publish |
Publish an episode |
POST |
/v2/podcasts/{showId}/episodes/{episodeId}/archive |
Archive an episode |
DELETE |
/v2/podcasts/{showId}/episodes/{episodeId} |
Delete an episode and its hosted audio |
POST |
/v2/podcasts/{showId}/subscribe |
Toggle show subscription |
POST |
/v2/podcasts/episodes/{episodeId}/reaction |
Toggle episode reaction |
POST |
/v2/podcasts/episodes/{episodeId}/report |
Report an episode |
Admin only:
| Method | Path | Purpose |
|---|---|---|
GET |
/v2/admin/podcasts |
Dashboard (shows, episodes, stats, top episodes, reports, analytics) |
POST |
/v2/admin/podcasts/shows/{id}/moderate |
Approve/reject/flag a show |
GET |
/v2/admin/podcasts/shows/{id}/validate-feed |
Validate a show's RSS feed |
POST |
/v2/admin/podcasts/episodes/{id}/moderate |
Approve/reject/flag an episode |
POST |
/v2/admin/podcasts/reports/{episodeId}/resolve |
Resolve episode reports |
GET |
/v2/admin/config/podcasts |
Get tenant podcast configuration |
PUT |
/v2/admin/config/podcasts/bulk |
Update tenant podcast configuration |
Audio handling¶
Episodes support two audio storage modes, set at upload time:
Hosted audio — a file is uploaded via multipart/form-data with the audio field. PodcastService::storeHostedAudio() validates the MIME type (mp3, m4a, aac, wav, ogg, webm accepted), stores the file on the configured disk (local by default, or a cloud disk when podcasts.media_storage_driver is set to cloud), and writes audio_storage_path and audio_storage_disk on the episode. The audio_url is set to a signed in-app proxy URL (/v2/podcasts/media/{tenantId}/{episodeId}/audio) that expires after one hour and uses HMAC signature verification.
External audio — a plain HTTPS URL is accepted in the audio_url field. Hosted-audio columns are left null. The RSS feed uses the external URL directly for the <enclosure> element.
Media pipeline (async): when a file is uploaded with podcasts.enable_media_processing or podcasts.enable_media_scanning enabled, the ProcessPodcastEpisodeMedia queue job fires (app/Jobs/ProcessPodcastEpisodeMedia.php). It retries up to three times with a 30-second backoff. The current implementation is a provision hook — it marks unscanned media as scan_unavailable rather than clean, preventing unreviewed audio from being labelled safe. Real scanner and transcoder integrations can be dropped in here. If all retries are exhausted, media_processing_status is set to failed and a warning is logged.
Audio delivery (GET /v2/podcasts/media/{tenantId}/{episodeId}/audio):
- Local disk: served as a
BinaryFileResponsewithAccept-Ranges: bytesfor Range support (seekable in browsers). - Cloud disk: redirected to a 10-minute temporary URL via
Storage::disk(...)->temporaryUrl(). If the driver package is missing (e.g.league/flysystem-aws-s3-v3not installed), the route falls back to the in-app proxy rather than 500-ing. This fallback is regression-tested intests/Laravel/Feature/Services/PodcastEpisodeAudioUrlFallbackTest.php. - Members-only or private episodes require either an active session or a valid HMAC signature (
?expires=<ts>&signature=<hex>). Public episodes served through RSS get unsigned URLs so aggregators can fetch without signing.
Allowed MIME types: audio/mpeg, audio/mp3, audio/mp4, audio/aac, audio/wav, audio/x-wav, audio/ogg, audio/webm, video/webm. Default max upload size is 250 MB (configurable per tenant via podcasts.max_audio_size_mb).
RSS / podcast distribution¶
When podcasts.enable_rss_feed is true (the default), each public show exposes an iTunes-compatible RSS 2.0 feed with Podcast Namespace 1.0 extensions.
Feed URL: GET /v2/podcasts/{showSlug}/feed.xml
Aggregator-stable URL: GET /v2/podcasts/feed/{tenantId}/{showSlug}.xml
The feed includes:
- iTunes channel metadata:
<itunes:author>,<itunes:owner>,<itunes:category>,<itunes:image>,<itunes:explicit>,<copyright>. <podcast:funding>whenfunding_urlis set.- Per-episode
<enclosure>(withlengthandtype),<itunes:duration>(HH:MM:SS),<itunes:episodeType>(full/trailer/bonus),<itunes:season>,<itunes:episode>. <podcast:transcript>(plain-text URL) whenpodcasts.enable_transcriptsistrueand a transcript is stored.<podcast:chapters>(JSON Podcast Namespace chapters URL) whenpodcasts.enable_chaptersistrueand chapters exist.
The feed only includes published, approved, public-or-inherit-visible episodes. Future-scheduled episodes are excluded until their scheduled_for time arrives. Up to 300 episodes are included per feed.
Episodes with hosted audio use unsigned proxy URLs in the feed so podcast aggregators can fetch audio without time-expiring signatures.
GET /v2/admin/podcasts/shows/{id}/validate-feed runs a pre-submission validation and returns {valid, errors[], warnings[]} for feed-level problems (missing artwork, missing owner_email) and per-episode problems (missing audio URL or MIME type).
Scheduled publishing¶
Episodes can be scheduled by setting scheduled_for to a future timestamp when creating or updating an episode. Publishing such an episode sets published_at = scheduled_for and does not immediately notify subscribers.
The podcasts:release-due Artisan command (backed by ReleaseScheduledPodcastEpisodes) should run on a frequent schedule (every minute is appropriate). It queries all published, approved, not-yet-announced episodes whose scheduled_for has passed, sets announced_at via a conditional UPDATE (preventing duplicate announcement across concurrent runs), posts a feed activity, and notifies subscribers.
The announced_at column acts as an idempotent guard: the publish path, the moderation-approval path, and the scheduler all call PodcastService::announceEpisode(), which uses UPDATE ... WHERE announced_at IS NULL and returns early if the row was already claimed.
To run manually:
Subscriptions and notifications¶
Members subscribe to a show via POST /v2/podcasts/{showId}/subscribe, which toggles the subscription. The notify_new_episodes field (default true) controls whether the subscriber receives an in-app notification when a new episode goes live. Subscriber counts on podcast_shows.subscriber_count are kept in sync with a lockForUpdate recount to avoid race conditions.
Listen analytics¶
When podcasts.enable_listen_analytics is true (the default), POST /v2/podcasts/episodes/{episodeId}/listen records a listen event. The deduplication window is six hours: within that window, a second listen from the same session hash (or user ID, or IP+UA hash combination) updates the existing row's listened_seconds (taking the maximum) rather than creating a new row. listened_seconds is also clamped to duration_seconds to prevent client inflation of completion metrics.
Stored fields are privacy-preserving: IP, user-agent, and session ID are stored only as SHA-256 hashes. No raw PII is retained.
The admin dashboard (GET /v2/admin/podcasts) returns aggregate stats:
- Total and completed listen counts, completion rate, unique listener count.
- Client-family breakdown (browser/app family extracted from user-agent).
- Retention bucket breakdown (what proportion of episodes listeners completed: 0–25%, 25–50%, 50–75%, 75–100%, 100%+).
- Top 10 episodes by
listen_count.
Moderation¶
When podcasts.moderation_enabled is true, every newly created or published show and episode is placed in moderation_status = pending and must be approved by an admin before it appears publicly.
When moderation is off (the default), content is set to approved immediately on creation.
Episode reports from members are stored in podcast_episode_reports. When moderation is enabled, the first report sets moderation_status = flagged and hides the episode. When moderation is off, auto-flagging requires reports from at least three distinct members (REPORT_AUTO_FLAG_THRESHOLD = 3) to prevent a single bad actor from suppressing content. Admins resolve reports via POST /v2/admin/podcasts/reports/{episodeId}/resolve (status: resolved, dismissed, or escalated); resolving or dismissing clears the auto-flag and restores the episode.
Tenant-level configuration¶
All settings are stored in tenant_settings under the podcasts category and read/written by PodcastConfigurationService. Values are cached for 5 minutes per tenant.
| Key | Default | Purpose |
|---|---|---|
podcasts.allow_member_show_creation |
true |
When false, only admins can create shows |
podcasts.moderation_enabled |
false |
Pre-publication review queue for all content |
podcasts.enable_rss_feed |
true |
Expose feed.xml endpoints |
podcasts.enable_private_shows |
true |
Allow members and private visibility |
podcasts.enable_transcripts |
true |
Accept and expose episode transcripts |
podcasts.enable_chapters |
true |
Accept and expose chapter markers |
podcasts.enable_episode_reactions |
true |
Reactions on episodes |
podcasts.enable_listen_analytics |
true |
Record listen events |
podcasts.max_shows_per_user |
5 |
Per-user show limit (0 = unlimited) |
podcasts.max_audio_size_mb |
250 |
Upload size ceiling |
podcasts.media_storage_driver |
local |
local or cloud |
podcasts.cloud_storage_disk |
s3 |
Laravel disk name for cloud storage |
podcasts.cloud_cdn_base_url |
(empty) | CDN prefix for cloud audio URLs |
podcasts.enable_media_scanning |
true |
Queue a scan job after audio upload |
podcasts.enable_media_processing |
true |
Queue a processing job after audio upload |
Configuration can be read and written via the admin API (GET /v2/admin/config/podcasts, PUT /v2/admin/config/podcasts/bulk) or through the admin React panel under Podcasts settings.
Visibility and access control¶
Show visibility: public (anonymous access), members (logged-in only), private (owner and admins only). Episode visibility: inherit (follows the show), or overridden to public, members, or private.
PodcastService::canViewShow() and canViewEpisode() enforce these rules. The show and episode API endpoints return HTTP 404 (not 403) when access is denied to avoid disclosing the existence of private content.
Future-scheduled episodes are hidden from all non-owner/non-admin viewers even after they are "published" — the embargo is enforced both in canViewEpisode() and in the scopePublished() Eloquent scope used by browse queries and RSS.
React frontend entry points¶
| Page | Route | Notes |
|---|---|---|
| Browse shows | /podcasts |
Public, feature-gated, search + category + sort filters |
| Show detail | /podcasts/:showSlug |
Public or members-only depending on visibility |
| Episode detail | /podcasts/:showSlug/:episodeSlug |
Public or members-only |
| Podcast Studio | /podcasts/studio |
Auth required; show/episode management |
The PodcastAudioPlayer component (react-frontend/src/components/podcasts/PodcastAudioPlayer.tsx) handles in-browser playback. After playback begins, the player posts listen events to the API to record progress.
Security and privacy invariants¶
- All queries include a
tenant_idfilter. The Eloquent global scope onPodcastShowandPodcastEpisodeenforces this automatically; the handful ofwithoutGlobalScopes()calls (audio proxy, transcript, chapters, RSS by tenant ID) setTenantContextexplicitly before querying. - Hosted audio URLs for non-public content are signed with HMAC (app-key-derived secret, 1-hour TTL). The signature is verified on every audio request via
hasValidMediaSignature(). - Listen analytics store only hashed PII (SHA-256 of IP, user-agent, session ID). No raw values are persisted.
- Episode reports require authentication; the report reason must be non-empty.
- Upload MIME types are validated against an allowlist; the storage path uses
bin2hex(random_bytes(12))for the filename to prevent enumeration. - Content reports auto-flag episodes at a threshold of three distinct reporters (when moderation is off) to mitigate single-reporter griefing of creators.
- Cloud audio redirect uses
temporaryUrl()(10-minute expiry) rather than a long-lived public URL. IftemporaryUrl()is unavailable on the configured disk, the route returns 404 rather than leaking the storage path.
Tests¶
# Feature / integration tests
vendor/bin/phpunit --filter PodcastControllerTest
vendor/bin/phpunit --filter PodcastEpisodeAudioUrlFallbackTest
vendor/bin/phpunit --filter PodcastConfigurationServiceTest
vendor/bin/phpunit --filter PodcastsCategoryParityTest
# All podcast-related tests in one run
vendor/bin/phpunit --filter Podcast
# E2E smoke
npx playwright test e2e/tests/podcasts/podcasts-smoke.spec.ts --project=chromium-modern
Key regression tests:
| Test | Guards against |
|---|---|
PodcastControllerTest |
Full CRUD, audio upload, listen dedup, subscription toggle, RSS output, report flow, auth/tenant isolation |
PodcastEpisodeAudioUrlFallbackTest |
Missing cloud disk driver (e.g. league/flysystem-aws-s3-v3 not installed) causing a fatal on audio URL generation (Sentry NEXUS-PHP-2K) |
PodcastConfigurationServiceTest |
Default values, typed persistence, tenant scoping of config |
PodcastsCategoryParityTest |
Accessible-frontend category filter parity with the React browse page |
Failure modes and recovery¶
Audio upload fails mid-write — the storeHostedAudio() method throws \InvalidArgumentException('Podcast media storage failed'). The episode row is already saved with audio_url = 'podcast-hosted://pending'. The creator must delete and re-upload. There is no automatic retry for failed initial uploads.
Media job exhausts retries — ProcessPodcastEpisodeMedia::failed() sets media_processing_status = failed and logs a warning. The episode is still accessible but the scan or processing result is unavailable. Monitor via the admin dashboard stat pending_media_processing.
Cloud disk driver unavailable — audio URL generation falls back to the in-app proxy (/v2/podcasts/media/…/audio) instead of throwing, preventing a 500 on episode detail pages. The fallback is logged at warning level so it is visible in application logs and Sentry.
Scheduled episode missed — if the podcasts:release-due command is not running (scheduler down), episodes past their scheduled_for will not be announced. Re-running the command at any time will announce all outstanding due episodes. The announced_at guard prevents double-notifications.
Duplicate announce race — two concurrent processes (e.g. publish + scheduler) calling announceEpisode() at the same moment are protected by the conditional UPDATE WHERE announced_at IS NULL. Only one wins the row; the other exits early without re-notifying subscribers.
RSS feed returns 404 — common causes: show visibility is not public, status is not published, moderation_status is not approved, or podcasts.enable_rss_feed is false. Run GET /v2/admin/podcasts/shows/{id}/validate-feed to get a structured error list.