Pre-Save Campaign System
A complete pre-save campaign system for music releases, integrated with streaming platforms like Spotify, Tidal, and Apple Music.
What is a Pre-Save Campaign?
A pre-save campaign allows fans to register their interest in an upcoming music release before it's officially available. When the release date arrives, the track/album is automatically added to their library on their chosen streaming platform.
This is a common marketing tool in the music industry to:
- Build hype for upcoming releases
- Guarantee first-day streams
- Increase algorithmic visibility on streaming platforms
- Track fan engagement and reach
System Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Admin Creates │───▶│ Fans Register │───▶│ Release Day │
│ Campaign │ │ via OAuth │ │ Processing │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
Admin Interface Public Landing Page Background Jobs
CRUD Operations OAuth Integration Auto-Add to Library
Campaign Management User Registration Status Tracking
Core Components
1. Value Objects (ValueObjects/)
StreamingPlatform - Supported streaming platforms
- SPOTIFY, TIDAL, APPLE_MUSIC, DEEZER, YOUTUBE_MUSIC
- Methods:
getDisplayName(),getOAuthProvider(),isSupported()
CampaignStatus - Campaign lifecycle states
- DRAFT → SCHEDULED → ACTIVE → RELEASED → COMPLETED
- Methods:
acceptsRegistrations(),shouldProcess(),isEditable()
RegistrationStatus - User registration states
- PENDING → COMPLETED / FAILED / REVOKED
- Methods:
canRetry(),isFinal()
TrackUrl - Platform-specific track URL with ID extraction
- Automatic platform detection from URL
- Track ID extraction for API calls
- Methods:
fromUrl(),getPlatformId()
2. Entities
PreSaveCampaign - Campaign entity
PreSaveCampaign::create(
title: 'New Album',
artistName: 'The Artist',
coverImageUrl: 'https://...',
releaseDate: Timestamp::fromString('2024-12-01 00:00'),
trackUrls: [TrackUrl::fromUrl('https://open.spotify.com/track/...')],
description: 'Optional description',
startDate: Timestamp::now()
);
Lifecycle Methods:
publish()- Publish draft campaign (sets status to SCHEDULED or ACTIVE)markAsReleased()- Mark as released when release date passesmarkAsCompleted()- Mark as completed after processingcancel()- Cancel campaign at any time
PreSaveRegistration - User registration
PreSaveRegistration::create(
campaignId: 1,
userId: 'spotify_user_123',
platform: StreamingPlatform::SPOTIFY
);
Status Methods:
markAsCompleted()- Mark as successfully processedmarkAsFailed()- Mark as failed with error messageresetForRetry()- Reset for retry attempt
3. Repositories
PreSaveCampaignRepository
findById(int $id): ?PreSaveCampaignfindAll(): arraysave(PreSaveCampaign $campaign): PreSaveCampaigndelete(int $id): void
PreSaveRegistrationRepository
findByCampaign(int $campaignId): arrayfindByUserAndCampaign(string $userId, int $campaignId, StreamingPlatform $platform): ?PreSaveRegistrationfindPendingForCampaign(int $campaignId): arrayfindFailedRegistrations(int $maxRetries = 3): arraysave(PreSaveRegistration $registration): PreSaveRegistrationdelete(int $id): void
4. Services
PreSaveCampaignService - Business logic layer
public function getCampaignStats(int $campaignId): array
{
return [
'total_registrations' => 150,
'by_platform' => [
'spotify' => 100,
'tidal' => 30,
'apple_music' => 20
],
'by_status' => [
'pending' => 120,
'completed' => 25,
'failed' => 5
]
];
}
PreSaveProcessor - Release day processing
Core Methods:
processReleasedCampaigns()- Check for campaigns that should be releasedprocessPendingRegistrations()- Process all pending registrationsretryFailedRegistrations(int $campaignId, int $maxRetries)- Retry failed registrations
Processing Flow:
// 1. Mark campaigns as released
$releaseResults = $processor->processReleasedCampaigns();
// 2. Process pending registrations
$processingResults = $processor->processPendingRegistrations();
// 3. Retry failed registrations (separate job)
$retryResults = $processor->retryFailedRegistrations($campaignId, 3);
5. Background Jobs
ProcessReleasedCampaignsJob - Runs every hour
#[Schedule(interval: Duration::fromMinutes(60))]
public function handle(): array
{
// Step 1: Mark campaigns as released
$releaseResults = $this->processor->processReleasedCampaigns();
// Step 2: Process pending registrations
$processingResults = $this->processor->processPendingRegistrations();
return [
'release_phase' => $releaseResults,
'processing_phase' => $processingResults
];
}
RetryFailedRegistrationsJob - Runs every 6 hours
#[Schedule(interval: Duration::fromHours(6))]
public function handle(): array
{
$campaigns = $this->campaignRepository->findAll([
'status' => CampaignStatus::RELEASED->value
]);
foreach ($campaigns as $campaign) {
$result = $this->processor->retryFailedRegistrations($campaign->id, 3);
// Process retry results
}
}
6. OAuth Integration
The system integrates with the OAuth module for authentication:
// User clicks "Pre-Save on Spotify"
$authUrl = $this->oauthService->getAuthorizationUrl(
'spotify',
'/presave/123/oauth/callback/spotify'
);
// After OAuth authorization
$token = $this->oauthService->handleCallback('spotify', $code, $callbackUrl);
// Token is stored and auto-refreshed
$storedToken = $this->oauthService->getTokenForUser($userId, 'spotify');
// Use token to add tracks
$provider->addTracksToLibrary($storedToken->token, [$trackId]);
Admin Interface
Campaign Management
Admin Routes:
GET /admin/presave-campaigns- List all campaignsGET /admin/presave-campaigns/create- Create campaign formPOST /admin/presave-campaigns- Store new campaignGET /admin/presave-campaigns/{id}- View campaign statsGET /admin/presave-campaigns/{id}/edit- Edit campaign formPOST /admin/presave-campaigns/{id}- Update campaignPOST /admin/presave-campaigns/{id}/delete- Delete campaignPOST /admin/presave-campaigns/{id}/publish- Publish draft campaignPOST /admin/presave-campaigns/{id}/cancel- Cancel campaign
Admin Features:
- Create campaigns with multiple platform URLs
- Edit campaign details (title, artist, cover image, description)
- Set release date and optional campaign start date
- Manage campaign status (draft, scheduled, active, cancelled)
- View campaign statistics and registrations
- Publish campaigns to make them public
- Cancel campaigns at any time
Campaign Form Fields
- title: Campaign title (required)
- artist_name: Artist name (required)
- cover_image_url: Album/single artwork URL (required)
- description: Campaign description (optional)
- release_date: When track will be released (required)
- start_date: When to start accepting pre-saves (optional)
- track_url_spotify: Spotify track URL
- track_url_tidal: Tidal track URL (optional)
- track_url_apple_music: Apple Music track URL (optional)
Public Interface
End-User Flow
Public Routes:
GET /presave/{id}- Campaign landing pagePOST /presave/{id}/register/{platform}- Register for pre-saveGET /presave/{id}/oauth/callback/{platform}- OAuth callbackPOST /presave/{id}/cancel/{platform}- Cancel registrationGET /presave/{id}/status- Get registration status (JSON)
User Journey:
- Visit campaign landing page:
/presave/123 - Click "Pre-Save on Spotify"
- Redirect to Spotify OAuth authorization
- Callback stores token and creates registration
- Redirect back to landing page with success message
- On release day, background job adds track to library
- User sees "✓ Added to Library" status
Campaign Landing Page Features
- Album artwork display
- Track/album title and artist
- Release date badge
- Optional campaign description
- Platform selection buttons (Spotify, Tidal, etc.)
- Registration status display
- Success/cancellation messages
- Auto-refresh status for pending registrations
- Mobile-responsive design
Database Schema
presave_campaigns Table
CREATE TABLE presave_campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title VARCHAR(255) NOT NULL,
artist_name VARCHAR(255) NOT NULL,
cover_image_url TEXT NOT NULL,
description TEXT,
release_date INTEGER NOT NULL,
start_date INTEGER,
track_urls TEXT NOT NULL, -- JSON array of TrackUrl objects
status VARCHAR(20) NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_presave_campaigns_status ON presave_campaigns(status);
CREATE INDEX idx_presave_campaigns_release_date ON presave_campaigns(release_date);
presave_registrations Table
CREATE TABLE presave_registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL,
user_id VARCHAR(255) NOT NULL,
platform VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
registered_at INTEGER NOT NULL,
processed_at INTEGER,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(campaign_id, user_id, platform),
FOREIGN KEY (campaign_id) REFERENCES presave_campaigns(id) ON DELETE CASCADE
);
CREATE INDEX idx_presave_registrations_campaign_id ON presave_registrations(campaign_id);
CREATE INDEX idx_presave_registrations_user_id ON presave_registrations(user_id);
CREATE INDEX idx_presave_registrations_status ON presave_registrations(status);
CREATE INDEX idx_presave_registrations_platform ON presave_registrations(platform);
Campaign Lifecycle
Status Flow
DRAFT
│
├─ publish() → SCHEDULED (if release_date in future)
├─ publish() → ACTIVE (if release_date has passed)
└─ cancel() → CANCELLED
SCHEDULED
│
├─ (on start_date) → ACTIVE
├─ (on release_date) → RELEASED
└─ cancel() → CANCELLED
ACTIVE
│
├─ (on release_date) → RELEASED
└─ cancel() → CANCELLED
RELEASED
│
└─ (after processing) → COMPLETED
COMPLETED / CANCELLED
│
└─ [FINAL STATE]
Registration Flow
PENDING
│
├─ (on success) → COMPLETED
├─ (on error) → FAILED
└─ (user action) → REVOKED
FAILED
│
├─ (retry success) → COMPLETED
└─ (max retries) → [STAYS FAILED]
COMPLETED / REVOKED
│
└─ [FINAL STATE]
Platform Integration
Currently Supported
Spotify ✅
- OAuth 2.0 authorization
- Pre-save implementation via
addTracksToLibrary() - Track ID extraction from Spotify URLs
- Library status checking
Planned Support
Tidal 🔄 (In Development) Apple Music 🔄 (In Development) Deezer 📋 (Planned) YouTube Music 📋 (Planned)
Adding New Platform
- Update
StreamingPlatformenum:
case NEW_PLATFORM = 'new_platform';
public function isSupported(): bool
{
return match ($this) {
self::NEW_PLATFORM => true, // Enable support
// ...
};
}
- Implement OAuth provider in
/src/Framework/OAuth/Providers/:
final readonly class NewPlatformProvider implements OAuthProvider
{
public function addTracksToLibrary(OAuthToken $token, array $trackIds): bool
{
// Platform-specific implementation
}
}
- Update
TrackUrl::fromUrl()to support new URL format:
if (str_contains($url, 'newplatform.com/track/')) {
preg_match('/track\/([a-zA-Z0-9]+)/', $url, $matches);
return new self(StreamingPlatform::NEW_PLATFORM, $url, $matches[1]);
}
Error Handling
Failed Registrations
When a registration fails:
- Status set to
FAILED - Error message stored in
error_messagefield - Retry count incremented
RetryFailedRegistrationsJobattempts retry (max 3 attempts)- After max retries, registration stays FAILED
Common Failure Scenarios
- Token Expired: Auto-refresh handled by
OAuthService - API Rate Limit: Retry with exponential backoff
- Invalid Track ID: Permanent failure, needs manual correction
- Platform API Down: Retry until success or max attempts
Performance Considerations
Background Processing
- Campaigns checked hourly for release
- Registrations processed in batches
- Failed registrations retried every 6 hours
- Auto-refresh tokens prevent auth failures
Scalability
- Unique constraint prevents duplicate registrations
- Indexes on frequently queried fields (status, campaign_id, user_id)
- CASCADE delete for data integrity
- Efficient batch processing in workers
Testing
Unit Tests
./vendor/bin/pest tests/Domain/PreSave
Integration Tests
# Test complete flow
./vendor/bin/pest tests/Feature/PreSaveFlowTest.php
Manual Testing
- Create campaign in admin:
/admin/presave-campaigns/create - Publish campaign:
/admin/presave-campaigns/{id}/publish - Visit public page:
/presave/{id} - Register via OAuth
- Run worker manually:
php console.php schedule:run ProcessReleasedCampaignsJob
- Check registration status
Security Considerations
OAuth Security
- Tokens stored encrypted in database
- Auto-refresh prevents token expiration
- State parameter prevents CSRF attacks
- Secure callback URL validation
Input Validation
- Track URLs validated and sanitized
- Campaign dates validated
- Platform enum ensures only supported platforms
- Unique constraint prevents duplicate registrations
Data Privacy
- User IDs from OAuth providers
- No personally identifiable information stored
- Registrations deleted on campaign deletion (CASCADE)
- Secure token storage and handling
Framework Compliance
Value Objects ✅
- All domain concepts as Value Objects
- No primitive obsession (no raw arrays or strings)
- Immutable with transformation methods
Readonly Classes ✅
- All entities and VOs are
readonly - No mutable state
- New instances for changes
No Inheritance ✅
- Composition over inheritance
- No
extendsused anywhere - Interface-based contracts
Explicit DI ✅
- Constructor injection only
- No global state or service locators
- Clear dependency graph
Monitoring & Metrics
Key Metrics
- Total campaigns created
- Active campaigns
- Total registrations
- Registrations by platform
- Success/failure rates
- Processing times
Health Checks
// Check system health
GET /admin/api/presave-campaigns/health
// Response
{
"status": "healthy",
"campaigns": {
"total": 10,
"active": 3,
"scheduled": 5
},
"registrations": {
"total": 1500,
"pending": 200,
"completed": 1250,
"failed": 50
},
"workers": {
"last_run": "2024-01-20 15:30:00",
"status": "running"
}
}
Future Enhancements
Planned Features
- Email notifications for successful pre-saves
- SMS notifications for release day
- Social sharing for campaigns
- Campaign analytics dashboard
- A/B testing for landing pages
- Multi-track album pre-saves
- Playlist pre-saves
- Fan engagement metrics
- Export campaign data
Platform Expansion
- Complete Tidal integration
- Complete Apple Music integration
- Add Deezer support
- Add YouTube Music support
- Add SoundCloud support
- Add Bandcamp support
Troubleshooting
Registration Not Processing
Symptom: Registrations stay in PENDING status
Solutions:
- Check if scheduled job is running:
php console.php schedule:list - Check campaign status: Must be RELEASED
- Verify OAuth token is valid: Auto-refresh should handle this
- Check error logs for API failures
OAuth Flow Failing
Symptom: Authorization redirect fails or callback errors
Solutions:
- Verify OAuth credentials in environment
- Check callback URL matches registered URL
- Ensure HTTPS is enabled (required for OAuth)
- Check provider API status
Platform API Rate Limits
Symptom: Multiple FAILED registrations with rate limit errors
Solutions:
- Reduce processing batch size
- Increase delay between API calls
- Implement exponential backoff
- Contact platform for rate limit increase
Support
For issues or questions:
- Check logs:
storage/logs/ - Review error messages in admin interface
- Check background job status
- Contact development team
License
Part of Custom PHP Framework - Internal Use Only