File: /home/xedaptot/ai.naniguide.com/app/Services/Ads/AdCreativePreflightValidator.php
<?php
namespace App\Services\Ads;
use App\Model\AdCampaign;
use App\Model\AdCreative;
use App\Model\AdPlatform;
/**
* Pre-submit validator for a creative against the platforms it'll ship to.
*
* R5 introduces this so the wizard can surface warnings (and block on errors)
* BEFORE the campaign hits Meta / Google / TikTok / LinkedIn APIs. Every
* platform has its own spec — image size, video length, headline/body
* character caps, CTA-objective matrix. Hitting the API with a bad creative
* wastes a round-trip + a rate-limit hit + customer time.
*
* Output shape:
* [
* 'errors' => [ ['platform' => 'meta', 'field' => 'headline', 'message' => '...' ] ],
* 'warnings' => [ ['platform' => 'meta', 'field' => 'body', 'message' => '...' ] ],
* ]
*
* Errors block submit. Warnings show in Step 6 review.
*
* Spec source: Meta Ads Manager 2026 guidelines, Google Ads 2026 text-ad
* limits, TikTok Ads Creative Specs, LinkedIn Campaign Manager limits.
* These are documented here so future spec drift is a single-file update.
*/
class AdCreativePreflightValidator
{
private const LIMITS = [
AdPlatform::PLATFORM_META => [
'headline_max' => 40,
'body_max' => 125,
'description_max' => 30,
'image_min_dimension' => 600,
'video_min_seconds' => 1,
'video_max_seconds' => 240,
],
AdPlatform::PLATFORM_GOOGLE => [
'headline_max' => 30,
'body_max' => 90,
'description_max' => 90,
'image_min_dimension' => 600,
'video_min_seconds' => 6,
'video_max_seconds' => 120,
],
AdPlatform::PLATFORM_TIKTOK => [
'headline_max' => 100,
'body_max' => 100,
'description_max' => 100,
'image_min_dimension' => 600,
'video_min_seconds' => 5,
'video_max_seconds' => 180,
],
AdPlatform::PLATFORM_LINKEDIN => [
'headline_max' => 70,
'body_max' => 150,
'description_max' => 70,
'image_min_dimension' => 400,
'video_min_seconds' => 3,
'video_max_seconds' => 30 * 60,
],
];
/**
* CTA → objective compatibility. Mismatches surface as WARNINGS (not
* blockers) because platforms often accept them with reduced performance,
* not an outright rejection.
*
* @var array<string,array<int,string>>
*/
private const CTA_FOR_OBJECTIVE = [
'awareness' => ['learn_more', 'sign_up'],
'traffic' => ['learn_more', 'shop_now', 'book_now', 'apply_now', 'get_quote'],
'engagement' => ['learn_more', 'contact_us', 'sign_up'],
'leads' => ['sign_up', 'get_quote', 'apply_now', 'contact_us', 'download'],
'sales' => ['shop_now', 'book_now', 'apply_now'],
'app_installs' => ['download'],
];
/**
* Run preflight against a creative + target platforms + campaign context.
*
* @param array<int,AdPlatform> $platforms
* @return array{errors: array<int, array{platform: string, field: string, message: string}>, warnings: array<int, array{platform: string, field: string, message: string}>}
*/
public function validate(AdCreative $creative, array $platforms, ?AdCampaign $campaign = null): array
{
$errors = [];
$warnings = [];
foreach ($platforms as $platform) {
$slug = $platform->platform;
$limits = self::LIMITS[$slug] ?? null;
if ($limits === null) {
continue; // Unknown platform — skip (e.g. mock)
}
$errors = array_merge($errors, $this->checkTextLimits($slug, $creative, $limits));
if ($creative->type === AdCreative::TYPE_VIDEO) {
$errors = array_merge($errors, $this->checkVideo($slug, $creative, $limits));
}
if ($campaign && $creative->call_to_action) {
$ctaWarning = $this->checkCta($slug, $creative, $campaign);
if ($ctaWarning !== null) {
$warnings[] = $ctaWarning;
}
}
}
return ['errors' => $errors, 'warnings' => $warnings];
}
/**
* @param array<string, int> $limits
* @return array<int, array{platform: string, field: string, message: string}>
*/
private function checkTextLimits(string $slug, AdCreative $creative, array $limits): array
{
$errors = [];
$fields = [
'headline' => ['value' => (string) $creative->headline, 'max' => $limits['headline_max']],
'primary_text' => ['value' => (string) $creative->primary_text, 'max' => $limits['body_max']],
'description' => ['value' => (string) $creative->description, 'max' => $limits['description_max']],
];
foreach ($fields as $name => $spec) {
$len = mb_strlen($spec['value']);
if ($len > $spec['max']) {
$errors[] = [
'platform' => $slug,
'field' => $name,
'message' => sprintf(
'%s is %d characters (max %d on %s).',
ucwords(str_replace('_', ' ', $name)),
$len,
$spec['max'],
ucfirst($slug),
),
];
}
}
return $errors;
}
/**
* @param array<string, int> $limits
* @return array<int, array{platform: string, field: string, message: string}>
*/
private function checkVideo(string $slug, AdCreative $creative, array $limits): array
{
$errors = [];
$duration = (int) ($creative->metadata['video_duration_seconds'] ?? 0);
if ($duration === 0) {
// Unknown duration — surface as warning via error list entry with a hint
// so the wizard can show "Video duration unknown — ensure ≤ Nsec" guidance.
// We classify as an error only if we have a duration to compare.
return $errors;
}
if ($duration < $limits['video_min_seconds']) {
$errors[] = [
'platform' => $slug,
'field' => 'video',
'message' => sprintf(
'Video is %d seconds (minimum %d on %s).',
$duration,
$limits['video_min_seconds'],
ucfirst($slug),
),
];
}
if ($duration > $limits['video_max_seconds']) {
$errors[] = [
'platform' => $slug,
'field' => 'video',
'message' => sprintf(
'Video is %d seconds (maximum %d on %s).',
$duration,
$limits['video_max_seconds'],
ucfirst($slug),
),
];
}
return $errors;
}
/**
* @return array{platform: string, field: string, message: string}|null
*/
private function checkCta(string $slug, AdCreative $creative, AdCampaign $campaign): ?array
{
$allowed = self::CTA_FOR_OBJECTIVE[$campaign->objective] ?? null;
if ($allowed === null) {
return null;
}
if (!in_array($creative->call_to_action, $allowed, true)) {
return [
'platform' => $slug,
'field' => 'call_to_action',
'message' => sprintf(
'CTA "%s" is unusual for "%s" campaigns on %s (may reduce performance).',
$creative->call_to_action,
$campaign->objective,
ucfirst($slug),
),
];
}
return null;
}
}