Social Manager: Laravel 11 project files

I can’t attach downloadable files in this chat, so everything below is copy-paste ready, with the path above each block. It’s complete enough to run, but I haven’t executed it, so expect small fixes.

0. Setup
bash
composer create-project laravel/laravel social-manager
cd social-manager
composer require laravel/socialite predis/predis
php artisan queue:table && php artisan queue:failed-table

.env additions:

DB_CONNECTION=mysql
QUEUE_CONNECTION=redis
FILESYSTEM_DISK=public

OPENAI_API_KEY=
OPENAI_IMAGE_MODEL=gpt-image-1
REPLICATE_API_TOKEN=
REPLICATE_VIDEO_MODEL=owner/model-name   # check replicate.com for a current text-to-video model

META_GRAPH_VERSION=v21.0
TELEGRAM_BOT_TOKEN=

config/services.php (add inside the array):

php
'openai'    => ['key' => env('OPENAI_API_KEY'), 'image_model' => env('OPENAI_IMAGE_MODEL')],
'replicate' => ['token' => env('REPLICATE_API_TOKEN'), 'video_model' => env('REPLICATE_VIDEO_MODEL')],
'meta'      => ['version' => env('META_GRAPH_VERSION', 'v21.0')],
'telegram'  => ['token' => env('TELEGRAM_BOT_TOKEN')],
1. database/schema.sql
sql
CREATE DATABASE IF NOT EXISTS social_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE social_manager;

CREATE TABLE users (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  email_verified_at TIMESTAMP NULL,
  password VARCHAR(255) NOT NULL,
  timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
  phone VARCHAR(32) NULL,
  telegram_chat_id VARCHAR(64) NULL,
  ai_credits INT NOT NULL DEFAULT 50,
  remember_token VARCHAR(100) NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL
) ENGINE=InnoDB;

CREATE TABLE social_accounts (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  platform ENUM('facebook','instagram','x','linkedin','tiktok','youtube') NOT NULL,
  external_id VARCHAR(191) NOT NULL,
  name VARCHAR(255) NOT NULL,
  access_token TEXT NOT NULL,
  refresh_token TEXT NULL,
  token_expires_at TIMESTAMP NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  UNIQUE KEY uq_user_platform_ext (user_id, platform, external_id),
  KEY idx_token_exp (token_expires_at),
  CONSTRAINT fk_sa_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE posts (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  caption TEXT NOT NULL,
  status ENUM('draft','scheduled','publishing','published','partial','failed') NOT NULL DEFAULT 'draft',
  scheduled_at DATETIME NULL COMMENT 'UTC',
  published_at DATETIME NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  KEY idx_due (status, scheduled_at),
  CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE post_targets (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  post_id BIGINT UNSIGNED NOT NULL,
  social_account_id BIGINT UNSIGNED NOT NULL,
  status ENUM('pending','publishing','published','failed') NOT NULL DEFAULT 'pending',
  external_post_id VARCHAR(191) NULL,
  permalink VARCHAR(500) NULL,
  error TEXT NULL,
  attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
  published_at DATETIME NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  KEY idx_post (post_id),
  CONSTRAINT fk_pt_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
  CONSTRAINT fk_pt_account FOREIGN KEY (social_account_id) REFERENCES social_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE media (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  post_id BIGINT UNSIGNED NULL,
  type ENUM('image','video') NOT NULL,
  source ENUM('upload','ai') NOT NULL DEFAULT 'upload',
  path VARCHAR(500) NULL,
  prompt TEXT NULL,
  provider VARCHAR(50) NULL,
  provider_job_id VARCHAR(191) NULL,
  status ENUM('pending','processing','ready','failed') NOT NULL DEFAULT 'ready',
  error TEXT NULL,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  KEY idx_post_media (post_id),
  KEY idx_pending (status),
  CONSTRAINT fk_media_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  CONSTRAINT fk_media_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE SET NULL
) ENGINE=InnoDB;

CREATE TABLE notification_rules (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  event ENUM('post_published','post_failed','token_expiring','video_ready') NOT NULL,
  channel ENUM('mail','telegram','sms') NOT NULL,
  is_enabled TINYINT(1) NOT NULL DEFAULT 1,
  UNIQUE KEY uq_rule (user_id, event, channel),
  CONSTRAINT fk_nr_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE api_logs (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  platform VARCHAR(30) NOT NULL,
  endpoint VARCHAR(255) NOT NULL,
  status_code SMALLINT NULL,
  response TEXT NULL,
  created_at TIMESTAMP NULL
) ENGINE=InnoDB;

Laravel also needs sessions, cache, jobs, and failed_jobs tables. Run php artisan migrate once to create those, or skip it by importing this file and using the default migrations only for those four.

2. Models

app/Models/SocialAccount.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class SocialAccount extends Model
{
    protected $guarded = [];
    protected $hidden = ['access_token', 'refresh_token'];
    protected $casts = [
        'access_token'     => 'encrypted',
        'refresh_token'    => 'encrypted',
        'token_expires_at' => 'datetime',
    ];

    public function user() { return $this->belongsTo(User::class); }
}

app/Models/Post.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $guarded = [];
    protected $casts = ['scheduled_at' => 'datetime', 'published_at' => 'datetime'];

    public function user()    { return $this->belongsTo(User::class); }
    public function targets() { return $this->hasMany(PostTarget::class); }
    public function media()   { return $this->hasMany(Media::class); }

    public function mediaReady(): bool
    {
        return !$this->media()->whereIn('status', ['pending', 'processing', 'failed'])->exists();
    }
}

app/Models/PostTarget.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PostTarget extends Model
{
    protected $guarded = [];
    protected $casts = ['published_at' => 'datetime'];

    public function post()          { return $this->belongsTo(Post::class); }
    public function socialAccount() { return $this->belongsTo(SocialAccount::class); }
}

app/Models/Media.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

class Media extends Model
{
    protected $table = 'media';
    protected $guarded = [];

    public function url(): ?string
    {
        return $this->path ? Storage::disk(config('filesystems.default'))->url($this->path) : null;
    }
}

Add to app/Models/User.php:

php
public function posts()          { return $this->hasMany(Post::class); }
public function socialAccounts() { return $this->hasMany(SocialAccount::class); }
public function media()          { return $this->hasMany(Media::class); }
public function notificationRules() { return $this->hasMany(NotificationRule::class); }

app/Models/NotificationRule.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class NotificationRule extends Model
{
    public $timestamps = false;
    protected $guarded = [];
}
3. Scheduler

routes/console.php

php
<?php
use Illuminate\Support\Facades\Schedule;

Schedule::command('posts:dispatch-due')->everyMinute()->withoutOverlapping();
Schedule::command('tokens:refresh')->dailyAt('02:00');

app/Console/Commands/DispatchDuePosts.php

php
<?php
namespace App\Console\Commands;

use App\Jobs\PublishToPlatform;
use App\Models\Post;
use Illuminate\Console\Command;

class DispatchDuePosts extends Command
{
    protected $signature = 'posts:dispatch-due';
    protected $description = 'Dispatch scheduled posts whose time has come';

    public function handle(): int
    {
        Post::where('status', 'scheduled')
            ->where('scheduled_at', '<=', now())
            ->with('targets')
            ->each(function (Post $post) {
                if (!$post->mediaReady()) {
                    return; // AI media still generating; try again next minute
                }
                $post->update(['status' => 'publishing']);
                foreach ($post->targets as $target) {
                    PublishToPlatform::dispatch($target);
                }
            });

        return self::SUCCESS;
    }
}

app/Console/Commands/RefreshTokens.php

php
<?php
namespace App\Console\Commands;

use App\Events\TokenExpiring;
use App\Models\SocialAccount;
use App\Services\Publishers\PublisherFactory;
use Illuminate\Console\Command;

class RefreshTokens extends Command
{
    protected $signature = 'tokens:refresh';
    protected $description = 'Refresh tokens expiring within 7 days';

    public function handle(PublisherFactory $factory): int
    {
        SocialAccount::where('is_active', 1)
            ->whereNotNull('token_expires_at')
            ->where('token_expires_at', '<=', now()->addDays(7))
            ->each(function (SocialAccount $acc) use ($factory) {
                try {
                    $factory->for($acc->platform)->refreshToken($acc);
                } catch (\Throwable $e) {
                    TokenExpiring::dispatch($acc);
                }
            });

        return self::SUCCESS;
    }
}
4. Publishers

app/Services/Publishers/Publisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\PostTarget;
use App\Models\SocialAccount;

interface Publisher
{
    /** @return array{id:string, url:?string} */
    public function publish(PostTarget $target): array;
    public function refreshToken(SocialAccount $account): void;
}

app/Services/Publishers/PublisherFactory.php

php
<?php
namespace App\Services\Publishers;

class PublisherFactory
{
    public function for(string $platform): Publisher
    {
        return match ($platform) {
            'facebook'  => app(FacebookPublisher::class),
            'instagram' => app(InstagramPublisher::class),
            default     => throw new \InvalidArgumentException("No publisher for {$platform}"),
        };
    }
}

app/Services/Publishers/FacebookPublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\PostTarget;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;

class FacebookPublisher implements Publisher
{
    private function base(): string
    {
        return 'https://graph.facebook.com/' . config('services.meta.version');
    }

    public function publish(PostTarget $target): array
    {
        $acc   = $target->socialAccount;
        $post  = $target->post;
        $media = $post->media()->where('status', 'ready')->first();
        $token = $acc->access_token; // Page access token

        if ($media && $media->type === 'image') {
            $res = Http::post("{$this->base()}/{$acc->external_id}/photos", [
                'url' => $media->url(), 'caption' => $post->caption, 'access_token' => $token,
            ]);
        } elseif ($media && $media->type === 'video') {
            $res = Http::timeout(120)->post("{$this->base()}/{$acc->external_id}/videos", [
                'file_url' => $media->url(), 'description' => $post->caption, 'access_token' => $token,
            ]);
        } else {
            $res = Http::post("{$this->base()}/{$acc->external_id}/feed", [
                'message' => $post->caption, 'access_token' => $token,
            ]);
        }

        $res->throw();
        $id = $res->json('post_id') ?? $res->json('id');

        return ['id' => $id, 'url' => "https://facebook.com/{$id}"];
    }

    public function refreshToken(SocialAccount $account): void
    {
        // Page tokens obtained from a long-lived user token don't expire.
        // Re-run the OAuth connect flow if Meta invalidates it.
    }
}

app/Services/Publishers/InstagramPublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\PostTarget;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;

class InstagramPublisher implements Publisher
{
    private function base(): string
    {
        return 'https://graph.facebook.com/' . config('services.meta.version');
    }

    public function publish(PostTarget $target): array
    {
        $acc   = $target->socialAccount;
        $post  = $target->post;
        $media = $post->media()->where('status', 'ready')->first();
        abort_if(!$media, 422, 'Instagram requires an image or video.');

        $params = ['caption' => $post->caption, 'access_token' => $acc->access_token];
        if ($media->type === 'video') {
            $params += ['media_type' => 'REELS', 'video_url' => $media->url()];
        } else {
            $params += ['image_url' => $media->url()];
        }

        $container = Http::post("{$this->base()}/{$acc->external_id}/media", $params)->throw()->json('id');

        // Poll until Instagram finishes processing (videos can take a while)
        for ($i = 0; $i < 30; $i++) {
            $status = Http::get("{$this->base()}/{$container}", [
                'fields' => 'status_code', 'access_token' => $acc->access_token,
            ])->json('status_code');

            if ($status === 'FINISHED') break;
            if ($status === 'ERROR') throw new \RuntimeException('Instagram media processing failed.');
            sleep(5);
        }

        $id = Http::post("{$this->base()}/{$acc->external_id}/media_publish", [
            'creation_id' => $container, 'access_token' => $acc->access_token,
        ])->throw()->json('id');

        $permalink = Http::get("{$this->base()}/{$id}", [
            'fields' => 'permalink', 'access_token' => $acc->access_token,
        ])->json('permalink');

        return ['id' => $id, 'url' => $permalink];
    }

    public function refreshToken(SocialAccount $account): void {}
}
5. Jobs

app/Jobs/PublishToPlatform.php

php
<?php
namespace App\Jobs;

use App\Events\PostFailed;
use App\Events\PostPublished;
use App\Models\PostTarget;
use App\Services\Publishers\PublisherFactory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class PublishToPlatform implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public array $backoff = [60, 300, 900];
    public int $timeout = 300;

    public function __construct(public PostTarget $target) {}

    public function handle(PublisherFactory $factory): void
    {
        $this->target->increment('attempts');
        $this->target->update(['status' => 'publishing']);

        $result = $factory->for($this->target->socialAccount->platform)->publish($this->target);

        $this->target->update([
            'status' => 'published',
            'external_post_id' => $result['id'],
            'permalink' => $result['url'],
            'published_at' => now(),
            'error' => null,
        ]);

        $this->rollup();
        PostPublished::dispatch($this->target);
    }

    public function failed(\Throwable $e): void
    {
        $this->target->update(['status' => 'failed', 'error' => substr($e->getMessage(), 0, 2000)]);
        $this->rollup();
        PostFailed::dispatch($this->target);
    }

    private function rollup(): void
    {
        $post = $this->target->post()->with('targets')->first();
        $statuses = $post->targets->pluck('status');
        if ($statuses->contains(fn ($s) => in_array($s, ['pending', 'publishing']))) return;

        $post->update([
            'status' => $statuses->every(fn ($s) => $s === 'published') ? 'published'
                      : ($statuses->contains('published') ? 'partial' : 'failed'),
            'published_at' => now(),
        ]);
    }
}

app/Jobs/GenerateImage.php

php
<?php
namespace App\Jobs;

use App\Models\Media;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class GenerateImage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 2;
    public int $timeout = 180;

    public function __construct(public Media $media, public string $size = '1024x1024') {}

    public function handle(): void
    {
        $this->media->update(['status' => 'processing']);

        $res = Http::withToken(config('services.openai.key'))->timeout(150)
            ->post('https://api.openai.com/v1/images/generations', [
                'model'  => config('services.openai.image_model'),
                'prompt' => $this->media->prompt,
                'size'   => $this->size,
                'n'      => 1,
            ])->throw();

        $item = $res->json('data.0');
        $bytes = isset($item['b64_json']) ? base64_decode($item['b64_json']) : Http::get($item['url'])->body();

        $path = 'media/' . $this->media->user_id . '/' . Str::uuid() . '.png';
        Storage::disk(config('filesystems.default'))->put($path, $bytes);

        $this->media->update(['path' => $path, 'status' => 'ready']);
    }

    public function failed(\Throwable $e): void
    {
        $this->media->update(['status' => 'failed', 'error' => substr($e->getMessage(), 0, 2000)]);
        $this->media->user()->increment('ai_credits', 1); // refund
    }
}

app/Jobs/GenerateVideo.php

php
<?php
namespace App\Jobs;

use App\Models\Media;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

class GenerateVideo implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public Media $media) {}

    public function handle(): void
    {
        $model = config('services.replicate.video_model');

        $res = Http::withToken(config('services.replicate.token'))
            ->post("https://api.replicate.com/v1/models/{$model}/predictions", [
                'input' => ['prompt' => $this->media->prompt],
            ])->throw();

        $this->media->update([
            'provider' => 'replicate',
            'provider_job_id' => $res->json('id'),
            'status' => 'processing',
        ]);

        PollVideo::dispatch($this->media)->delay(now()->addSeconds(20));
    }
}

app/Jobs/PollVideo.php

php
<?php
namespace App\Jobs;

use App\Events\VideoReady;
use App\Models\Media;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class PollVideo implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;
    public int $timeout = 600;

    public function __construct(public Media $media, public int $round = 0) {}

    public function handle(): void
    {
        $res = Http::withToken(config('services.replicate.token'))
            ->get("https://api.replicate.com/v1/predictions/{$this->media->provider_job_id}")->throw();

        $status = $res->json('status');

        if (in_array($status, ['starting', 'processing'])) {
            if ($this->round > 120) { // ~40 min cap
                $this->fail('timeout');
                return $this->markFailed('Generation timed out.');
            }
            self::dispatch($this->media, $this->round + 1)->delay(now()->addSeconds(20));
            return;
        }

        if ($status !== 'succeeded') {
            $this->markFailed($res->json('error') ?? $status);
            return;
        }

        $output = $res->json('output');
        $url = is_array($output) ? $output[0] : $output;

        $disk = Storage::disk(config('filesystems.default'));
        $raw = 'tmp/' . Str::uuid() . '.mp4';
        $out = 'media/' . $this->media->user_id . '/' . Str::uuid() . '.mp4';
        $disk->put($raw, Http::timeout(300)->get($url)->body());

        // Normalize for social platforms: H.264/AAC, faststart
        $r = Process::timeout(300)->run([
            'ffmpeg', '-y', '-i', $disk->path($raw),
            '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'veryfast',
            '-c:a', 'aac', '-movflags', '+faststart',
            $disk->path($out),
        ]);

        $disk->delete($raw);

        if ($r->failed()) {
            $this->markFailed('FFmpeg failed: ' . Str::limit($r->errorOutput(), 500));
            return;
        }

        $this->media->update(['path' => $out, 'status' => 'ready']);
        VideoReady::dispatch($this->media);
    }

    private function markFailed(string $msg): void
    {
        $this->media->update(['status' => 'failed', 'error' => $msg]);
        $this->media->user()->increment('ai_credits', 5); // refund
    }
}
6. Events and notifications

app/Events/PostPublished.php (create PostFailed, TokenExpiring, VideoReady the same way, changing the property)

php
<?php
namespace App\Events;

use App\Models\PostTarget;
use Illuminate\Foundation\Events\Dispatchable;

class PostPublished
{
    use Dispatchable;
    public function __construct(public PostTarget $target) {}
}
php
// PostFailed: same as above.
// TokenExpiring: public SocialAccount $account
// VideoReady:    public Media $media

app/Providers/AppServiceProvider.php → in boot():

php
use Illuminate\Support\Facades\Event;

Event::listen(\App\Events\PostPublished::class, [\App\Listeners\NotifyUser::class, 'published']);
Event::listen(\App\Events\PostFailed::class,    [\App\Listeners\NotifyUser::class, 'failed']);
Event::listen(\App\Events\TokenExpiring::class, [\App\Listeners\NotifyUser::class, 'tokenExpiring']);
Event::listen(\App\Events\VideoReady::class,    [\App\Listeners\NotifyUser::class, 'videoReady']);

app/Listeners/NotifyUser.php

php
<?php
namespace App\Listeners;

use App\Events\{PostFailed, PostPublished, TokenExpiring, VideoReady};
use App\Notifications\GenericAlert;

class NotifyUser
{
    public function published(PostPublished $e): void
    {
        $t = $e->target;
        $this->send($t->post->user, 'post_published', 'Post is live',
            "Published to {$t->socialAccount->platform} ({$t->socialAccount->name}).", $t->permalink);
    }

    public function failed(PostFailed $e): void
    {
        $t = $e->target;
        $this->send($t->post->user, 'post_failed', 'Post failed',
            "Failed on {$t->socialAccount->platform}: {$t->error}");
    }

    public function tokenExpiring(TokenExpiring $e): void
    {
        $a = $e->account;
        $this->send($a->user, 'token_expiring', 'Reconnect your account',
            "Your {$a->platform} connection ({$a->name}) is expiring. Please reconnect.");
    }

    public function videoReady(VideoReady $e): void
    {
        $this->send($e->media->user, 'video_ready', 'Your AI video is ready', 'Open the composer to attach it.');
    }

    private function send($user, string $event, string $title, string $body, ?string $url = null): void
    {
        $channels = $user->notificationRules()
            ->where('event', $event)->where('is_enabled', 1)->pluck('channel')->all()
            ?: ['mail']; // default

        $via = array_map(fn ($c) => $c === 'telegram' ? \App\Channels\TelegramChannel::class : $c, $channels);
        $user->notify(new GenericAlert($title, $body, $url, $via));
    }
}

app/Notifications/GenericAlert.php

php
<?php
namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class GenericAlert extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $title, public string $body, public ?string $url, public array $channels
    ) {}

    public function via($notifiable): array { return $this->channels; }

    public function toMail($notifiable): MailMessage
    {
        $m = (new MailMessage)->subject($this->title)->line($this->body);
        if ($this->url) $m->action('View', $this->url);
        return $m;
    }

    public function toTelegram($notifiable): string
    {
        return "*{$this->title}*\n{$this->body}" . ($this->url ? "\n{$this->url}" : '');
    }
}

app/Channels/TelegramChannel.php

php
<?php
namespace App\Channels;

use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http;

class TelegramChannel
{
    public function send($notifiable, Notification $notification): void
    {
        if (!$notifiable->telegram_chat_id) return;

        Http::post('https://api.telegram.org/bot' . config('services.telegram.token') . '/sendMessage', [
            'chat_id' => $notifiable->telegram_chat_id,
            'text' => $notification->toTelegram($notifiable),
            'parse_mode' => 'Markdown',
        ]);
    }
}
7. Controllers and routes

routes/web.php

php
<?php
use App\Http\Controllers\{MediaController, PostController};
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::post('/posts', [PostController::class, 'store']);
    Route::post('/posts/{post}/schedule', [PostController::class, 'schedule']);
    Route::delete('/posts/{post}', [PostController::class, 'destroy']);

    Route::post('/media/upload', [MediaController::class, 'upload']);
    Route::post('/media/generate-image', [MediaController::class, 'generateImage']);
    Route::post('/media/generate-video', [MediaController::class, 'generateVideo']);
    Route::get('/media/{media}', [MediaController::class, 'show']); // poll status
});

app/Http/Controllers/PostController.php

php
<?php
namespace App\Http\Controllers;

use App\Models\{Media, Post};
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $r)
    {
        return $r->user()->posts()->with(['targets.socialAccount', 'media'])->latest()->paginate(20);
    }

    public function store(Request $r)
    {
        $data = $r->validate([
            'caption'      => 'required|string|max:5000',
            'accounts'     => 'required|array|min:1',
            'accounts.*'   => 'exists:social_accounts,id',
            'media_ids'    => 'array',
            'media_ids.*'  => 'exists:media,id',
            'scheduled_at' => 'nullable|date|after:now',
        ]);

        $user = $r->user();
        $ownedAccounts = $user->socialAccounts()->whereIn('id', $data['accounts'])->pluck('id');
        abort_if($ownedAccounts->count() !== count($data['accounts']), 403);

        $post = $user->posts()->create([
            'caption' => $data['caption'],
            'status'  => isset($data['scheduled_at']) ? 'scheduled' : 'draft',
            // client sends ISO with offset; stored as UTC
            'scheduled_at' => isset($data['scheduled_at'])
                ? \Carbon\Carbon::parse($data['scheduled_at'])->utc() : null,
        ]);

        foreach ($ownedAccounts as $id) {
            $post->targets()->create(['social_account_id' => $id]);
        }

        if (!empty($data['media_ids'])) {
            Media::where('user_id', $user->id)->whereIn('id', $data['media_ids'])
                ->update(['post_id' => $post->id]);
        }

        return response()->json($post->load('targets', 'media'), 201);
    }

    public function schedule(Request $r, Post $post)
    {
        abort_if($post->user_id !== $r->user()->id, 403);
        $data = $r->validate(['scheduled_at' => 'required|date|after:now']);

        $post->update([
            'status' => 'scheduled',
            'scheduled_at' => \Carbon\Carbon::parse($data['scheduled_at'])->utc(),
        ]);

        return $post;
    }

    public function destroy(Request $r, Post $post)
    {
        abort_if($post->user_id !== $r->user()->id, 403);
        abort_if($post->status === 'publishing', 409, 'Post is publishing.');
        $post->delete();
        return response()->noContent();
    }
}

app/Http/Controllers/MediaController.php

php
<?php
namespace App\Http\Controllers;

use App\Jobs\{GenerateImage, GenerateVideo};
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class MediaController extends Controller
{
    private const IMAGE_COST = 1;
    private const VIDEO_COST = 5;

    public function upload(Request $r)
    {
        $r->validate(['file' => 'required|file|mimes:jpg,jpeg,png,webp,mp4,mov|max:512000']);
        $file = $r->file('file');
        $type = str_starts_with($file->getMimeType(), 'video') ? 'video' : 'image';
        $path = $file->store("media/{$r->user()->id}", config('filesystems.default'));

        return Media::create([
            'user_id' => $r->user()->id, 'type' => $type,
            'source' => 'upload', 'path' => $path, 'status' => 'ready',
        ]);
    }

    public function generateImage(Request $r)
    {
        $data = $r->validate(['prompt' => 'required|string|max:1000', 'size' => 'in:1024x1024,1024x1536,1536x1024']);
        $this->charge($r, self::IMAGE_COST);

        $m = Media::create([
            'user_id' => $r->user()->id, 'type' => 'image', 'source' => 'ai',
            'prompt' => $data['prompt'], 'status' => 'pending', 'provider' => 'openai',
        ]);
        GenerateImage::dispatch($m, $data['size'] ?? '1024x1024');

        return response()->json($m, 202);
    }

    public function generateVideo(Request $r)
    {
        $data = $r->validate(['prompt' => 'required|string|max:1000']);
        $this->charge($r, self::VIDEO_COST);

        $m = Media::create([
            'user_id' => $r->user()->id, 'type' => 'video', 'source' => 'ai',
            'prompt' => $data['prompt'], 'status' => 'pending',
        ]);
        GenerateVideo::dispatch($m);

        return response()->json($m, 202);
    }

    public function show(Request $r, Media $media)
    {
        abort_if($media->user_id !== $r->user()->id, 403);
        return $media->append('url');
    }

    private function charge(Request $r, int $cost): void
    {
        $affected = \App\Models\User::where('id', $r->user()->id)
            ->where('ai_credits', '>=', $cost)->decrement('ai_credits', $cost);
        abort_if(!$affected, 402, 'Not enough AI credits.');
    }
}

In Media.php, make append('url') work by adding:

php
public function getUrlAttribute(): ?string { return $this->url(); }

(and rename the method to fileUrl() and update its calls to avoid a clash, or keep the method and drop the accessor and append).

8. Facebook/Instagram connect flow (OAuth)

routes/web.php (add, outside the group where noted):

php
Route::middleware('auth')->get('/connect/meta', [\App\Http\Controllers\MetaConnectController::class, 'redirect']);
Route::middleware('auth')->get('/connect/meta/callback', [\App\Http\Controllers\MetaConnectController::class, 'callback']);

app/Http/Controllers/MetaConnectController.php

php
<?php
namespace App\Http\Controllers;

use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;

class MetaConnectController extends Controller
{
    public function redirect()
    {
        return Socialite::driver('facebook')->scopes([
            'pages_show_list', 'pages_manage_posts', 'pages_read_engagement',
            'instagram_basic', 'instagram_content_publish',
        ])->redirect();
    }

    public function callback()
    {
        $v = config('services.meta.version');
        $userToken = Socialite::driver('facebook')->user()->token;

        $pages = Http::get("https://graph.facebook.com/{$v}/me/accounts", [
            'access_token' => $userToken,
            'fields' => 'id,name,access_token,instagram_business_account{id,username}',
        ])->throw()->json('data', []);

        foreach ($pages as $p) {
            SocialAccount::updateOrCreate(
                ['user_id' => auth()->id(), 'platform' => 'facebook', 'external_id' => $p['id']],
                ['name' => $p['name'], 'access_token' => $p['access_token']]
            );

            if (!empty($p['instagram_business_account'])) {
                $ig = $p['instagram_business_account'];
                SocialAccount::updateOrCreate(
                    ['user_id' => auth()->id(), 'platform' => 'instagram', 'external_id' => $ig['id']],
                    ['name' => '@' . ($ig['username'] ?? $ig['id']), 'access_token' => $p['access_token']]
                );
            }
        }

        return redirect('/posts')->with('status', 'Accounts connected.');
    }
}

config/services.php:

php
'facebook' => [
    'client_id'     => env('FACEBOOK_CLIENT_ID'),
    'client_secret' => env('FACEBOOK_CLIENT_SECRET'),
    'redirect'      => env('APP_URL') . '/connect/meta/callback',
],
9. Server config

Cron:

* * * * * cd /var/www/social-manager && php artisan schedule:run >> /dev/null 2>&1

/etc/supervisor/conf.d/social-worker.conf:

ini
[program:social-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/social-manager/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=3
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/social-worker.log
stopwaitsecs=3600
bash
sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start social-worker:*
sudo apt install ffmpeg
php artisan storage:link
10. Quick test
bash
# 1. import schema.sql, create a user, connect accounts via /connect/meta
# 2. generate an image
curl -X POST localhost:8000/media/generate-image -H "Accept: application/json" \
  -d '{"prompt":"minimal flat illustration of a coffee cup, pastel"}' --cookie "..."
# 3. create a scheduled post
curl -X POST localhost:8000/posts -H "Content-Type: application/json" \
  -d '{"caption":"Hello","accounts":[1],"media_ids":[1],"scheduled_at":"2026-10-11T09:00:00+03:00"}'
# 4. run php artisan queue:work and php artisan schedule:work locally
Known gaps / next steps
No frontend yet. These are JSON endpoints, so Livewire or Vue composer and calendar pages would be the next piece.
X, LinkedIn, TikTok, YouTube publishers aren’t included. They’d plug into PublisherFactory the same way.
Facebook/Instagram need a public media URL, so use S3 (or an equivalent) rather than localhost storage in production.
Meta App Review is required before the scopes work for users other than app testers.
Media::url() / accessor clash is noted in section 7. Pick one approach.

Tell me which piece you want next (the Vue/Livewire composer, the X and LinkedIn publishers, or a docker-compose file) and I’ll write it out in full.

To run code, enable code execution and file creation in Settings → Capabilities.

we need more model esp ones with free tiers and supported social media this will be used by different companies. include X, LinkedIn, TikTok, YouTube too

Multi-company version: more AI providers, X, LinkedIn, TikTok, YouTube

This is an upgrade to the previous project. I haven’t run it, and free tiers and platform API rules change often, so check each provider’s pricing page before launch. Model names are in .env so you can swap them without touching code.

1. What changes
Multi-tenant: every record belongs to an organization (a company). Users can belong to many organizations with roles (owner, admin, editor, viewer).
AI credits move from the user to the organization.
Each company can bring its own AI keys, which are stored encrypted. Your platform keys act as the fallback.
AI provider chain: try providers in priority order and fall through on failure or rate limit. Free providers go first.
Four new publishers with a shared OAuth flow and automatic token refresh.
2. AI providers (verify current free limits)
Kind	Provider	Free tier (as I last knew it)	Env
Image	Pollinations	Free, key optional	POLLINATIONS_API_KEY
Image	Cloudflare Workers AI (FLUX schnell)	Daily free allowance	CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
Image	Hugging Face Inference	Monthly free credits	HF_TOKEN
Image	Together AI	Has had a free FLUX schnell endpoint	TOGETHER_API_KEY
Image	Google Gemini image models	Free tier varies by model/region	GEMINI_API_KEY
Image	OpenAI	Paid	OPENAI_API_KEY
Video	fal.ai	Signup credits	FAL_KEY
Video	Replicate	Small trial, then paid	REPLICATE_API_TOKEN
Text (captions)	Groq, Gemini, OpenRouter (:free models), OpenAI	Mostly free tiers with rate limits	GROQ_API_KEY, etc.

Free tiers for video are rare and small. Treat video as paid or bring-your-own-key. Luma and Runway plug into the same VideoProvider interface below.

3. database/schema_v2.sql (run on top of the first schema)
sql
USE social_manager;

CREATE TABLE organizations (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(100) NOT NULL UNIQUE,
  plan ENUM('free','pro','business') NOT NULL DEFAULT 'free',
  ai_credits INT NOT NULL DEFAULT 100,
  timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL
) ENGINE=InnoDB;

CREATE TABLE organization_user (
  organization_id BIGINT UNSIGNED NOT NULL,
  user_id BIGINT UNSIGNED NOT NULL,
  role ENUM('owner','admin','editor','viewer') NOT NULL DEFAULT 'editor',
  created_at TIMESTAMP NULL,
  PRIMARY KEY (organization_id, user_id),
  KEY idx_ou_user (user_id),
  CONSTRAINT fk_ou_org  FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE,
  CONSTRAINT fk_ou_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE org_ai_providers (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  organization_id BIGINT UNSIGNED NOT NULL,
  kind ENUM('image','video','text') NOT NULL,
  provider VARCHAR(40) NOT NULL,
  credentials TEXT NULL COMMENT 'encrypted JSON: api_key, account_id...',
  model VARCHAR(191) NULL,
  priority TINYINT UNSIGNED NOT NULL DEFAULT 10,
  is_enabled TINYINT(1) NOT NULL DEFAULT 1,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  UNIQUE KEY uq_org_kind_provider (organization_id, kind, provider),
  CONSTRAINT fk_oap_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE ai_usage (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  organization_id BIGINT UNSIGNED NOT NULL,
  user_id BIGINT UNSIGNED NULL,
  kind ENUM('image','video','text') NOT NULL,
  provider VARCHAR(40) NULL,
  model VARCHAR(191) NULL,
  credits INT NOT NULL DEFAULT 0,
  status ENUM('ok','failed') NOT NULL DEFAULT 'ok',
  created_at TIMESTAMP NULL,
  KEY idx_usage_org (organization_id, created_at),
  CONSTRAINT fk_usage_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
) ENGINE=InnoDB;

-- ---- add organization_id to existing tables ----
ALTER TABLE social_accounts ADD COLUMN organization_id BIGINT UNSIGNED NULL AFTER id,
                            ADD COLUMN added_by BIGINT UNSIGNED NULL,
                            ADD KEY idx_sa_user (user_id);
ALTER TABLE posts ADD COLUMN organization_id BIGINT UNSIGNED NULL AFTER id,
                  ADD COLUMN title VARCHAR(150) NULL,
                  ADD COLUMN approved_by BIGINT UNSIGNED NULL;
ALTER TABLE media ADD COLUMN organization_id BIGINT UNSIGNED NULL AFTER id,
                  ADD COLUMN model VARCHAR(191) NULL,
                  ADD COLUMN provider_meta JSON NULL,
                  ADD COLUMN aspect_ratio VARCHAR(10) NULL;
ALTER TABLE api_logs ADD COLUMN organization_id BIGINT UNSIGNED NULL;
ALTER TABLE users ADD COLUMN current_organization_id BIGINT UNSIGNED NULL;

-- ---- backfill: one workspace per existing user ----
INSERT INTO organizations (name, slug, ai_credits, created_at, updated_at)
SELECT CONCAT(name, '''s workspace'), CONCAT('ws-', id), ai_credits, NOW(), NOW() FROM users;

INSERT INTO organization_user (organization_id, user_id, role, created_at)
SELECT o.id, u.id, 'owner', NOW() FROM users u JOIN organizations o ON o.slug = CONCAT('ws-', u.id);

UPDATE users u JOIN organizations o ON o.slug = CONCAT('ws-', u.id) SET u.current_organization_id = o.id;
UPDATE social_accounts t JOIN users u ON u.id = t.user_id SET t.organization_id = u.current_organization_id, t.added_by = t.user_id;
UPDATE posts  t JOIN users u ON u.id = t.user_id SET t.organization_id = u.current_organization_id;
UPDATE media  t JOIN users u ON u.id = t.user_id SET t.organization_id = u.current_organization_id;

-- ---- tighten ----
ALTER TABLE social_accounts
  MODIFY organization_id BIGINT UNSIGNED NOT NULL,
  MODIFY platform ENUM('facebook','instagram','x','linkedin','tiktok','youtube') NOT NULL,
  DROP INDEX uq_user_platform_ext,
  ADD UNIQUE KEY uq_org_platform_ext (organization_id, platform, external_id),
  ADD CONSTRAINT fk_sa_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;

ALTER TABLE posts
  MODIFY organization_id BIGINT UNSIGNED NOT NULL,
  MODIFY status ENUM('draft','pending_approval','scheduled','publishing','published','partial','failed') NOT NULL DEFAULT 'draft',
  ADD KEY idx_posts_org (organization_id, status),
  ADD CONSTRAINT fk_posts_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;

ALTER TABLE media
  MODIFY organization_id BIGINT UNSIGNED NOT NULL,
  ADD CONSTRAINT fk_media_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
4. Multi-tenancy

app/Models/Organization.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Organization extends Model
{
    protected $guarded = [];

    public function members()     { return $this->belongsToMany(User::class)->withPivot('role'); }
    public function aiProviders() { return $this->hasMany(OrgAiProvider::class); }

    public function spend(int $n): bool
    {
        return (bool) static::whereKey($this->id)->where('ai_credits', '>=', $n)->decrement('ai_credits', $n);
    }

    public function refund(int $n): void { static::whereKey($this->id)->increment('ai_credits', $n); }
}

app/Models/OrgAiProvider.php

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class OrgAiProvider extends Model
{
    protected $guarded = [];
    protected $hidden = ['credentials'];
    protected $casts = ['credentials' => 'encrypted:array', 'is_enabled' => 'boolean'];
}

app/Models/Concerns/BelongsToOrganization.php

php
<?php
namespace App\Models\Concerns;

use App\Models\Organization;
use Illuminate\Database\Eloquent\Builder;

trait BelongsToOrganization
{
    protected static function bootBelongsToOrganization(): void
    {
        // Applies only during web requests where the middleware bound an org.
        // Console commands and queue jobs run unscoped.
        static::addGlobalScope('org', function (Builder $q) {
            if (app()->bound('currentOrg')) {
                $q->where($q->getModel()->getTable() . '.organization_id', app('currentOrg')->id);
            }
        });

        static::creating(function ($m) {
            if (!$m->organization_id && app()->bound('currentOrg')) {
                $m->organization_id = app('currentOrg')->id;
            }
        });
    }

    public function organization() { return $this->belongsTo(Organization::class); }
}

Add use BelongsToOrganization; to Post, SocialAccount, and Media. In User.php add:

php
public function organizations() { return $this->belongsToMany(Organization::class)->withPivot('role'); }

app/Http/Middleware/SetCurrentOrganization.php

php
<?php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class SetCurrentOrganization
{
    public function handle(Request $r, Closure $next)
    {
        $user = $r->user();
        $orgId = $r->header('X-Org-Id') ?: $r->session()->get('org_id') ?: $user->current_organization_id;

        $org = $user->organizations()->where('organizations.id', $orgId)->first()
            ?? $user->organizations()->first();
        abort_if(!$org, 403, 'No organization.');

        app()->instance('currentOrg', $org);
        $r->attributes->set('role', $org->pivot->role);
        return $next($r);
    }
}

Register it in bootstrap/app.php:

php
->withMiddleware(function (Middleware $m) {
    $m->alias(['org' => \App\Http\Middleware\SetCurrentOrganization::class]);
})

Change the route group to Route::middleware(['auth', 'org'])->group(...).

Role helper (app/Support/Roles.php):

php
<?php
namespace App\Support;

class Roles
{
    const LEVEL = ['viewer' => 1, 'editor' => 2, 'admin' => 3, 'owner' => 4];

    public static function require(\Illuminate\Http\Request $r, string $min): void
    {
        abort_if(self::LEVEL[$r->attributes->get('role')] < self::LEVEL[$min], 403, 'Insufficient role.');
    }
}

PostController changes. Replace the user-scoped queries with org-scoped ones:

php
public function index(Request $r)
{
    return Post::with(['targets.socialAccount', 'media'])->latest()->paginate(20); // scope applies automatically
}

public function store(Request $r)
{
    Roles::require($r, 'editor');
    $data = $r->validate([
        'title'        => 'nullable|string|max:150',
        'caption'      => 'required|string|max:5000',
        'accounts'     => 'required|array|min:1',
        'accounts.*'   => 'integer',
        'media_ids'    => 'array',
        'media_ids.*'  => 'integer',
        'scheduled_at' => 'nullable|date|after:now',
    ]);

    $accounts = SocialAccount::whereIn('id', $data['accounts'])->pluck('id'); // org-scoped
    abort_if($accounts->count() !== count($data['accounts']), 403);

    // editors need approval, admins/owners schedule directly
    $needsApproval = $r->attributes->get('role') === 'editor' && config('app.require_approval', false);

    $post = Post::create([
        'user_id' => $r->user()->id,
        'title' => $data['title'] ?? null,
        'caption' => $data['caption'],
        'status' => !isset($data['scheduled_at']) ? 'draft' : ($needsApproval ? 'pending_approval' : 'scheduled'),
        'scheduled_at' => isset($data['scheduled_at']) ? \Carbon\Carbon::parse($data['scheduled_at'])->utc() : null,
    ]);

    foreach ($accounts as $id) $post->targets()->create(['social_account_id' => $id]);
    if (!empty($data['media_ids'])) Media::whereIn('id', $data['media_ids'])->update(['post_id' => $post->id]);

    return response()->json($post->load('targets', 'media'), 201);
}

public function approve(Request $r, Post $post)
{
    Roles::require($r, 'admin');
    abort_if($post->status !== 'pending_approval', 409);
    $post->update(['status' => 'scheduled', 'approved_by' => $r->user()->id]);
    return $post;
}

Add the route Route::post('/posts/{post}/approve', [PostController::class, 'approve']);. In schedule() and destroy(), drop the user_id check, since the global scope handles tenancy, and add Roles::require($r, 'editor').

5. AI layer

config/ai.php

php
<?php
return [
    'cost' => ['image' => 1, 'video' => 5],

    // order tried when the org has no own keys (and as fallback after them). Free first.
    'default_chain' => [
        'image' => ['pollinations', 'cloudflare', 'huggingface', 'together', 'gemini', 'openai'],
        'video' => ['fal', 'replicate'],
        'text'  => ['groq', 'gemini', 'openrouter', 'openai'],
    ],

    'drivers' => [
        'image' => [
            'pollinations' => \App\Services\Ai\Image\PollinationsImage::class,
            'cloudflare'   => \App\Services\Ai\Image\CloudflareImage::class,
            'huggingface'  => \App\Services\Ai\Image\HuggingFaceImage::class,
            'together'     => \App\Services\Ai\Image\TogetherImage::class,
            'gemini'       => \App\Services\Ai\Image\GeminiImage::class,
            'openai'       => \App\Services\Ai\Image\OpenAiImage::class,
        ],
        'video' => [
            'fal'       => \App\Services\Ai\Video\FalVideo::class,
            'replicate' => \App\Services\Ai\Video\ReplicateVideo::class,
        ],
        'text' => [
            'groq' => \App\Services\Ai\Text\OpenAiCompatText::class,
            'gemini' => \App\Services\Ai\Text\OpenAiCompatText::class,
            'openrouter' => \App\Services\Ai\Text\OpenAiCompatText::class,
            'openai' => \App\Services\Ai\Text\OpenAiCompatText::class,
        ],
    ],

    // platform-level credentials (fallback when an org has none of its own)
    'providers' => [
        'pollinations' => ['api_key' => env('POLLINATIONS_API_KEY'), 'always' => true],
        'cloudflare'   => ['api_key' => env('CLOUDFLARE_API_TOKEN'), 'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
                           'model' => env('CLOUDFLARE_IMAGE_MODEL', '@cf/black-forest-labs/flux-1-schnell')],
        'huggingface'  => ['api_key' => env('HF_TOKEN'), 'model' => env('HF_IMAGE_MODEL', 'black-forest-labs/FLUX.1-schnell')],
        'together'     => ['api_key' => env('TOGETHER_API_KEY'), 'model' => env('TOGETHER_IMAGE_MODEL')],
        'gemini'       => ['api_key' => env('GEMINI_API_KEY'), 'model' => env('GEMINI_IMAGE_MODEL'), 'text_model' => env('GEMINI_TEXT_MODEL')],
        'openai'       => ['api_key' => env('OPENAI_API_KEY'), 'model' => env('OPENAI_IMAGE_MODEL', 'gpt-image-1'), 'text_model' => env('OPENAI_TEXT_MODEL')],
        'fal'          => ['api_key' => env('FAL_KEY'), 'model' => env('FAL_VIDEO_MODEL')],
        'replicate'    => ['api_key' => env('REPLICATE_API_TOKEN'), 'model' => env('REPLICATE_VIDEO_MODEL')],
        'groq'         => ['api_key' => env('GROQ_API_KEY'), 'text_model' => env('GROQ_TEXT_MODEL')],
        'openrouter'   => ['api_key' => env('OPENROUTER_API_KEY'), 'text_model' => env('OPENROUTER_TEXT_MODEL')],
    ],
];

app/Services/Ai/Contracts.php (put each in its own file if you prefer PSR-4)

php
<?php
namespace App\Services\Ai;

interface ImageProvider
{
    public function configured(array $c): bool;
    /** @return string raw image bytes (PNG/JPEG) */
    public function generate(string $prompt, string $size, array $c): string;
}

interface VideoProvider
{
    public function configured(array $c): bool;
    /** @return array provider meta to persist (job ids/urls) */
    public function submit(string $prompt, array $opts, array $c): array;
    /** @return array{status:'processing'|'succeeded'|'failed', url?:string, error?:string} */
    public function poll(array $meta, array $c): array;
}

interface TextProvider
{
    public function configured(array $c): bool;
    public function complete(string $system, string $prompt, array $c): string;
}

app/Services/Ai/AiManager.php

php
<?php
namespace App\Services\Ai;

use App\Models\Organization;

class AiManager
{
    /** @return array<int,array{0:object,1:array,2:string}> [driver, config, name] in priority order */
    public function chain(string $kind, ?Organization $org): array
    {
        $drivers = config("ai.drivers.$kind");
        $out = []; $seen = [];

        if ($org) {
            $rows = $org->aiProviders()->where('kind', $kind)->where('is_enabled', 1)->orderBy('priority')->get();
            foreach ($rows as $row) {
                if (!isset($drivers[$row->provider])) continue;
                $cfg = array_merge(
                    config("ai.providers.{$row->provider}", []),
                    array_filter(['model' => $row->model]),
                    $row->credentials ?? []
                );
                $cfg['name'] = $row->provider;
                $d = app($drivers[$row->provider]);
                if ($d->configured($cfg)) { $out[] = [$d, $cfg, $row->provider]; $seen[$row->provider] = true; }
            }
        }

        foreach (config("ai.default_chain.$kind") as $name) {
            if (isset($seen[$name]) || !isset($drivers[$name])) continue;
            $cfg = config("ai.providers.$name", []) + ['name' => $name];
            $d = app($drivers[$name]);
            if ($d->configured($cfg)) $out[] = [$d, $cfg, $name];
        }

        return $out;
    }
}
Image drivers

app/Services/Ai/Image/PollinationsImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class PollinationsImage implements ImageProvider
{
    public function configured(array $c): bool { return true; } // works without a key

    public function generate(string $prompt, string $size, array $c): string
    {
        [$w, $h] = array_map('intval', explode('x', $size));
        $url = 'https://image.pollinations.ai/prompt/' . rawurlencode($prompt) . '?' .
            http_build_query(['width' => $w, 'height' => $h, 'model' => 'flux', 'nologo' => 'true']);

        $req = Http::timeout(120);
        if (!empty($c['api_key'])) $req = $req->withToken($c['api_key']);
        return $req->get($url)->throw()->body();
    }
}

app/Services/Ai/Image/CloudflareImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class CloudflareImage implements ImageProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['account_id']); }

    public function generate(string $prompt, string $size, array $c): string
    {
        $res = Http::withToken($c['api_key'])->timeout(120)->post(
            "https://api.cloudflare.com/client/v4/accounts/{$c['account_id']}/ai/run/{$c['model']}",
            ['prompt' => $prompt, 'steps' => 4]
        )->throw();

        $b64 = $res->json('result.image');
        if (!$b64) throw new \RuntimeException('Cloudflare returned no image.');
        return base64_decode($b64);
    }
}

app/Services/Ai/Image/HuggingFaceImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class HuggingFaceImage implements ImageProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function generate(string $prompt, string $size, array $c): string
    {
        [$w, $h] = array_map('intval', explode('x', $size));
        $res = Http::withToken($c['api_key'])->timeout(150)->post(
            "https://router.huggingface.co/hf-inference/models/{$c['model']}",
            ['inputs' => $prompt, 'parameters' => ['width' => $w, 'height' => $h]]
        )->throw();
        return $res->body(); // image bytes
    }
}

app/Services/Ai/Image/TogetherImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class TogetherImage implements ImageProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function generate(string $prompt, string $size, array $c): string
    {
        [$w, $h] = array_map('intval', explode('x', $size));
        $res = Http::withToken($c['api_key'])->timeout(120)->post('https://api.together.xyz/v1/images/generations', [
            'model' => $c['model'], 'prompt' => $prompt, 'width' => $w, 'height' => $h,
            'steps' => 4, 'n' => 1, 'response_format' => 'b64_json',
        ])->throw();
        return base64_decode($res->json('data.0.b64_json'));
    }
}

app/Services/Ai/Image/GeminiImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class GeminiImage implements ImageProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function generate(string $prompt, string $size, array $c): string
    {
        $res = Http::withHeaders(['x-goog-api-key' => $c['api_key']])->timeout(120)->post(
            "https://generativelanguage.googleapis.com/v1beta/models/{$c['model']}:generateContent",
            [
                'contents' => [['parts' => [['text' => $prompt]]]],
                'generationConfig' => ['responseModalities' => ['TEXT', 'IMAGE']],
            ]
        )->throw();

        foreach ($res->json('candidates.0.content.parts', []) as $part) {
            if (isset($part['inlineData']['data'])) return base64_decode($part['inlineData']['data']);
        }
        throw new \RuntimeException('Gemini returned no image (possibly blocked by safety filters).');
    }
}

app/Services/Ai/Image/OpenAiImage.php

php
<?php
namespace App\Services\Ai\Image;

use App\Services\Ai\ImageProvider;
use Illuminate\Support\Facades\Http;

class OpenAiImage implements ImageProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function generate(string $prompt, string $size, array $c): string
    {
        $res = Http::withToken($c['api_key'])->timeout(150)->post('https://api.openai.com/v1/images/generations', [
            'model' => $c['model'], 'prompt' => $prompt, 'size' => $size, 'n' => 1,
        ])->throw();

        $item = $res->json('data.0');
        return isset($item['b64_json']) ? base64_decode($item['b64_json']) : Http::get($item['url'])->body();
    }
}
Video drivers

app/Services/Ai/Video/FalVideo.php

php
<?php
namespace App\Services\Ai\Video;

use App\Services\Ai\VideoProvider;
use Illuminate\Support\Facades\Http;

class FalVideo implements VideoProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function submit(string $prompt, array $opts, array $c): array
    {
        $res = Http::withHeaders(['Authorization' => 'Key ' . $c['api_key']])
            ->post("https://queue.fal.run/{$c['model']}", [
                'prompt' => $prompt,
                'aspect_ratio' => $opts['aspect'] ?? '9:16',
            ])->throw();

        return [
            'request_id'   => $res->json('request_id'),
            'status_url'   => $res->json('status_url'),
            'response_url' => $res->json('response_url'),
        ];
    }

    public function poll(array $meta, array $c): array
    {
        $h = ['Authorization' => 'Key ' . $c['api_key']];
        $status = Http::withHeaders($h)->get($meta['status_url'])->throw()->json('status');

        if (in_array($status, ['IN_QUEUE', 'IN_PROGRESS'])) return ['status' => 'processing'];
        if ($status !== 'COMPLETED') return ['status' => 'failed', 'error' => (string) $status];

        $out = Http::withHeaders($h)->get($meta['response_url'])->throw()->json();
        $url = $out['video']['url'] ?? $out['data']['video']['url'] ?? null;
        return $url ? ['status' => 'succeeded', 'url' => $url] : ['status' => 'failed', 'error' => 'No video URL in result.'];
    }
}

app/Services/Ai/Video/ReplicateVideo.php

php
<?php
namespace App\Services\Ai\Video;

use App\Services\Ai\VideoProvider;
use Illuminate\Support\Facades\Http;

class ReplicateVideo implements VideoProvider
{
    public function configured(array $c): bool { return !empty($c['api_key']) && !empty($c['model']); }

    public function submit(string $prompt, array $opts, array $c): array
    {
        $res = Http::withToken($c['api_key'])
            ->post("https://api.replicate.com/v1/models/{$c['model']}/predictions", [
                'input' => ['prompt' => $prompt, 'aspect_ratio' => $opts['aspect'] ?? '9:16'],
            ])->throw();
        return ['id' => $res->json('id')];
    }

    public function poll(array $meta, array $c): array
    {
        $r = Http::withToken($c['api_key'])->get("https://api.replicate.com/v1/predictions/{$meta['id']}")->throw();
        $s = $r->json('status');

        if (in_array($s, ['starting', 'processing'])) return ['status' => 'processing'];
        if ($s !== 'succeeded') return ['status' => 'failed', 'error' => $r->json('error') ?? $s];

        $o = $r->json('output');
        return ['status' => 'succeeded', 'url' => is_array($o) ? $o[0] : $o];
    }
}

Input field names (aspect_ratio, etc.) differ per video model, so check the model’s schema and adjust submit().

Text (captions), one driver for all OpenAI-compatible APIs

app/Services/Ai/Text/OpenAiCompatText.php

php
<?php
namespace App\Services\Ai\Text;

use App\Services\Ai\TextProvider;
use Illuminate\Support\Facades\Http;

class OpenAiCompatText implements TextProvider
{
    private const BASE = [
        'groq'       => 'https://api.groq.com/openai/v1',
        'gemini'     => 'https://generativelanguage.googleapis.com/v1beta/openai',
        'openrouter' => 'https://openrouter.ai/api/v1',
        'openai'     => 'https://api.openai.com/v1',
    ];

    public function configured(array $c): bool
    {
        return !empty($c['api_key']) && !empty($c['text_model']) && isset(self::BASE[$c['name'] ?? '']);
    }

    public function complete(string $system, string $prompt, array $c): string
    {
        return Http::withToken($c['api_key'])->timeout(60)
            ->post(self::BASE[$c['name']] . '/chat/completions', [
                'model' => $c['text_model'],
                'messages' => [
                    ['role' => 'system', 'content' => $system],
                    ['role' => 'user', 'content' => $prompt],
                ],
            ])->throw()->json('choices.0.message.content');
    }
}
Jobs (replace the earlier versions)

app/Jobs/GenerateImage.php

php
<?php
namespace App\Jobs;

use App\Models\Media;
use App\Services\Ai\AiManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class GenerateImage implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;
    public int $timeout = 400;

    public function __construct(public Media $media, public string $size = '1024x1024') {}

    public function handle(AiManager $ai): void
    {
        $this->media->update(['status' => 'processing']);
        $errors = [];

        foreach ($ai->chain('image', $this->media->organization) as [$driver, $cfg, $name]) {
            try {
                $bytes = $driver->generate($this->media->prompt, $this->size, $cfg);
                if (strlen($bytes) < 1000) throw new \RuntimeException('Empty image.');

                $ext = str_starts_with($bytes, "\xFF\xD8") ? 'jpg' : 'png';
                $path = "media/{$this->media->organization_id}/" . Str::uuid() . ".$ext";
                Storage::disk(config('filesystems.default'))->put($path, $bytes);

                $this->media->update(['path' => $path, 'status' => 'ready', 'provider' => $name, 'model' => $cfg['model'] ?? null]);
                $this->log($name, $cfg['model'] ?? null, 'ok');
                return;
            } catch (\Throwable $e) {
                $errors[] = "$name: " . substr($e->getMessage(), 0, 200);
                $this->log($name, $cfg['model'] ?? null, 'failed');
            }
        }

        $this->media->update(['status' => 'failed', 'error' => implode(' | ', $errors) ?: 'No provider configured.']);
        $this->media->organization->refund(config('ai.cost.image'));
    }

    private function log(string $provider, ?string $model, string $status): void
    {
        DB::table('ai_usage')->insert([
            'organization_id' => $this->media->organization_id, 'kind' => 'image',
            'provider' => $provider, 'model' => $model, 'status' => $status,
            'credits' => $status === 'ok' ? config('ai.cost.image') : 0, 'created_at' => now(),
        ]);
    }
}

app/Jobs/GenerateVideo.php

php
<?php
namespace App\Jobs;

use App\Models\Media;
use App\Services\Ai\AiManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class GenerateVideo implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public Media $media, public string $aspect = '9:16') {}

    public function handle(AiManager $ai): void
    {
        $errors = [];
        foreach ($ai->chain('video', $this->media->organization) as [$driver, $cfg, $name]) {
            try {
                $meta = $driver->submit($this->media->prompt, ['aspect' => $this->aspect], $cfg);
                $this->media->update([
                    'provider' => $name, 'model' => $cfg['model'] ?? null,
                    'provider_meta' => $meta, 'status' => 'processing', 'aspect_ratio' => $this->aspect,
                ]);
                PollVideo::dispatch($this->media)->delay(now()->addSeconds(20));
                return;
            } catch (\Throwable $e) {
                $errors[] = "$name: " . substr($e->getMessage(), 0, 200);
            }
        }

        $this->media->update(['status' => 'failed', 'error' => implode(' | ', $errors) ?: 'No video provider configured.']);
        $this->media->organization->refund(config('ai.cost.video'));
    }
}

app/Jobs/PollVideo.php

php
<?php
namespace App\Jobs;

use App\Events\VideoReady;
use App\Models\Media;
use App\Services\Ai\AiManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class PollVideo implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;
    public int $timeout = 900;

    public function __construct(public Media $media, public int $round = 0) {}

    public function handle(AiManager $ai): void
    {
        // Find the same provider (and config) that submitted the job
        $entry = collect($ai->chain('video', $this->media->organization))->first(fn ($e) => $e[2] === $this->media->provider);
        if (!$entry) { $this->fail_('Provider no longer configured.'); return; }
        [$driver, $cfg] = $entry;

        $r = $driver->poll($this->media->provider_meta ?? [], $cfg);

        if ($r['status'] === 'processing') {
            if ($this->round > 120) { $this->fail_('Generation timed out.'); return; }
            self::dispatch($this->media, $this->round + 1)->delay(now()->addSeconds(20));
            return;
        }
        if ($r['status'] === 'failed') { $this->fail_($r['error'] ?? 'Provider error.'); return; }

        $disk = Storage::disk(config('filesystems.default'));
        $raw = 'tmp/' . Str::uuid() . '.mp4';
        $out = "media/{$this->media->organization_id}/" . Str::uuid() . '.mp4';
        $disk->put($raw, Http::timeout(300)->get($r['url'])->body());

        // Download to local temp for FFmpeg (works for S3 too)
        $in = tempnam(sys_get_temp_dir(), 'in') . '.mp4';
        $outLocal = tempnam(sys_get_temp_dir(), 'out') . '.mp4';
        file_put_contents($in, $disk->get($raw));

        $p = Process::timeout(600)->run([
            'ffmpeg', '-y', '-i', $in,
            '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'veryfast',
            '-c:a', 'aac', '-movflags', '+faststart', $outLocal,
        ]);

        $disk->delete($raw);
        @unlink($in);

        if ($p->failed()) { @unlink($outLocal); $this->fail_('FFmpeg failed: ' . Str::limit($p->errorOutput(), 400)); return; }

        $disk->put($out, file_get_contents($outLocal));
        @unlink($outLocal);

        $this->media->update(['path' => $out, 'status' => 'ready']);
        VideoReady::dispatch($this->media);
    }

    private function fail_(string $msg): void
    {
        $this->media->update(['status' => 'failed', 'error' => $msg]);
        $this->media->organization->refund(config('ai.cost.video'));
    }
}

MediaController changes:

php
public function generateImage(Request $r)
{
    Roles::require($r, 'editor');
    $data = $r->validate(['prompt' => 'required|string|max:1000', 'size' => 'in:1024x1024,1024x1536,1536x1024']);
    $org = app('currentOrg');
    abort_if(!$org->spend(config('ai.cost.image')), 402, 'Not enough AI credits.');

    $m = Media::create([
        'user_id' => $r->user()->id, 'type' => 'image', 'source' => 'ai',
        'prompt' => $data['prompt'], 'status' => 'pending',
    ]); // organization_id set by trait
    GenerateImage::dispatch($m, $data['size'] ?? '1024x1024');
    return response()->json($m, 202);
}

public function generateVideo(Request $r)
{
    Roles::require($r, 'editor');
    $data = $r->validate(['prompt' => 'required|string|max:1000', 'aspect' => 'in:9:16,16:9,1:1']);
    abort_if(!app('currentOrg')->spend(config('ai.cost.video')), 402, 'Not enough AI credits.');

    $m = Media::create([
        'user_id' => $r->user()->id, 'type' => 'video', 'source' => 'ai',
        'prompt' => $data['prompt'], 'status' => 'pending',
    ]);
    GenerateVideo::dispatch($m, $data['aspect'] ?? '9:16');
    return response()->json($m, 202);
}

Uploads use $file->store("media/" . app('currentOrg')->id, ...). Add 'provider_meta' => 'array' to the Media casts and an organization() relation, which the trait already gives you.

Caption + AI settings controller (app/Http/Controllers/AiController.php):

php
<?php
namespace App\Http\Controllers;

use App\Models\OrgAiProvider;
use App\Services\Ai\AiManager;
use App\Support\Roles;
use Illuminate\Http\Request;

class AiController extends Controller
{
    public function caption(Request $r, AiManager $ai)
    {
        Roles::require($r, 'editor');
        $d = $r->validate([
            'topic' => 'required|string|max:1000',
            'platform' => 'required|in:facebook,instagram,x,linkedin,tiktok,youtube',
            'tone' => 'nullable|string|max:50',
        ]);
        $system = "You write {$d['platform']} social posts. Tone: " . ($d['tone'] ?? 'professional')
                . ". Respect the platform's length norms. Return only the post text.";

        foreach ($ai->chain('text', app('currentOrg')) as [$driver, $cfg]) {
            try { return ['caption' => $driver->complete($system, $d['topic'], $cfg)]; }
            catch (\Throwable $e) { continue; }
        }
        abort(503, 'No text provider available.');
    }

    // Org admins add their own keys
    public function saveProvider(Request $r)
    {
        Roles::require($r, 'admin');
        $d = $r->validate([
            'kind' => 'required|in:image,video,text',
            'provider' => 'required|string|in:' . implode(',', array_keys(array_merge(...array_values(config('ai.drivers'))))),
            'api_key' => 'required|string',
            'account_id' => 'nullable|string',
            'model' => 'nullable|string|max:191',
            'priority' => 'integer|min:1|max:100',
        ]);

        return OrgAiProvider::updateOrCreate(
            ['organization_id' => app('currentOrg')->id, 'kind' => $d['kind'], 'provider' => $d['provider']],
            [
                'credentials' => array_filter(['api_key' => $d['api_key'], 'account_id' => $d['account_id'] ?? null]),
                'model' => $d['model'] ?? null, 'priority' => $d['priority'] ?? 10, 'is_enabled' => true,
            ]
        );
    }
}
6. Social platforms

config/social.php

php
<?php
return [
    'x' => [
        'client_id' => env('X_CLIENT_ID'), 'client_secret' => env('X_CLIENT_SECRET'),
        'auth_url' => 'https://x.com/i/oauth2/authorize',
        'token_url' => 'https://api.x.com/2/oauth2/token',
        'scopes' => ['tweet.read', 'tweet.write', 'users.read', 'media.write', 'offline.access'],
        'scope_sep' => ' ', 'pkce' => true, 'auth_style' => 'basic', 'id_param' => 'client_id',
        'extra' => [],
    ],
    'linkedin' => [
        'client_id' => env('LINKEDIN_CLIENT_ID'), 'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
        'auth_url' => 'https://www.linkedin.com/oauth/v2/authorization',
        'token_url' => 'https://www.linkedin.com/oauth/v2/accessToken',
        'scopes' => ['openid', 'profile', 'w_member_social'],
        'scope_sep' => ' ', 'pkce' => false, 'auth_style' => 'body', 'id_param' => 'client_id',
        'extra' => [], 'api_version' => env('LINKEDIN_API_VERSION', '202506'),
    ],
    'tiktok' => [
        'client_id' => env('TIKTOK_CLIENT_KEY'), 'client_secret' => env('TIKTOK_CLIENT_SECRET'),
        'auth_url' => 'https://www.tiktok.com/v2/auth/authorize/',
        'token_url' => 'https://open.tiktokapis.com/v2/oauth/token/',
        'scopes' => ['user.info.basic', 'video.publish'],
        'scope_sep' => ',', 'pkce' => false, 'auth_style' => 'body', 'id_param' => 'client_key',
        'extra' => [], 'privacy' => env('TIKTOK_PRIVACY', 'PUBLIC_TO_EVERYONE'),
    ],
    'youtube' => [
        'client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'auth_url' => 'https://accounts.google.com/o/oauth2/v2/auth',
        'token_url' => 'https://oauth2.googleapis.com/token',
        'scopes' => ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube.readonly'],
        'scope_sep' => ' ', 'pkce' => false, 'auth_style' => 'body', 'id_param' => 'client_id',
        'extra' => ['access_type' => 'offline', 'prompt' => 'consent'],
        'privacy' => env('YOUTUBE_PRIVACY', 'public'),
    ],
];
Generic OAuth controller

app/Http/Controllers/OAuthController.php

php
<?php
namespace App\Http\Controllers;

use App\Models\SocialAccount;
use App\Services\Publishers\PublisherFactory;
use App\Support\Roles;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class OAuthController extends Controller
{
    public function redirect(Request $r, string $platform)
    {
        Roles::require($r, 'admin');
        $c = config("social.$platform");

        $state = Str::random(40);
        $verifier = Str::random(64);
        $r->session()->put("oauth.$state", [
            'platform' => $platform, 'org' => app('currentOrg')->id, 'verifier' => $verifier,
        ]);

        $params = [
            $c['id_param'] => $c['client_id'],
            'redirect_uri' => url("/connect/$platform/callback"),
            'response_type' => 'code',
            'scope' => implode($c['scope_sep'], $c['scopes']),
            'state' => $state,
        ] + $c['extra'];

        if ($c['pkce']) {
            $params['code_challenge'] = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
            $params['code_challenge_method'] = 'S256';
        }

        return redirect($c['auth_url'] . '?' . http_build_query($params));
    }

    public function callback(Request $r, string $platform, PublisherFactory $factory)
    {
        $saved = $r->session()->pull('oauth.' . $r->query('state'));
        abort_if(!$saved || $saved['platform'] !== $platform || !$r->query('code'), 400, 'Invalid OAuth state.');

        $c = config("social.$platform");
        $form = [
            'grant_type' => 'authorization_code',
            'code' => $r->query('code'),
            'redirect_uri' => url("/connect/$platform/callback"),
            $c['id_param'] => $c['client_id'],
        ];
        if ($c['pkce']) $form['code_verifier'] = $saved['verifier'];

        $req = Http::asForm();
        if ($c['auth_style'] === 'basic') $req = $req->withBasicAuth($c['client_id'], $c['client_secret']);
        else $form['client_secret'] = $c['client_secret'];

        $tok = $req->post($c['token_url'], $form)->throw()->json();

        foreach ($factory->for($platform)->discoverAccounts($tok['access_token']) as $a) {
            SocialAccount::withoutGlobalScopes()->updateOrCreate(
                ['organization_id' => $saved['org'], 'platform' => $platform, 'external_id' => $a['id']],
                [
                    'user_id' => auth()->id(), 'added_by' => auth()->id(), 'name' => $a['name'],
                    'access_token' => $tok['access_token'],
                    'refresh_token' => $tok['refresh_token'] ?? null,
                    'token_expires_at' => isset($tok['expires_in']) ? now()->addSeconds((int) $tok['expires_in']) : null,
                    'is_active' => 1,
                ]
            );
        }

        return redirect('/posts')->with('status', ucfirst($platform) . ' connected.');
    }
}

Routes (inside the auth + org group):

php
Route::get('/connect/{platform}', [OAuthController::class, 'redirect'])->where('platform', 'x|linkedin|tiktok|youtube');
Route::get('/connect/{platform}/callback', [OAuthController::class, 'callback'])->where('platform', 'x|linkedin|tiktok|youtube');
Route::post('/ai/caption', [AiController::class, 'caption']);
Route::post('/ai/providers', [AiController::class, 'saveProvider']);

Register each callback URL, APP_URL/connect/{platform}/callback, in the platform’s developer portal.

Publisher base + interface

Update Publisher.php and add discoverAccounts (return [] in the Facebook/Instagram classes, since the Meta controller handles them):

php
public function discoverAccounts(string $accessToken): array; // [['id'=>..., 'name'=>...]]

app/Services/Publishers/BasePublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\Media;
use App\Models\PostTarget;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Storage;

abstract class BasePublisher implements Publisher
{
    protected function token(SocialAccount $a): string
    {
        if ($a->token_expires_at && $a->token_expires_at->lt(now()->addMinutes(5))) {
            $this->refreshToken($a);
            $a->refresh();
        }
        return $a->access_token;
    }

    /** @return \Illuminate\Support\Collection<Media> */
    protected function media(PostTarget $t)
    {
        return $t->post->media()->where('status', 'ready')->orderBy('id')->get();
    }

    /** Copy any-disk file to a local temp path. Caller unlinks. */
    protected function tmp(Media $m): string
    {
        $path = tempnam(sys_get_temp_dir(), 'pub') . '.' . pathinfo($m->path, PATHINFO_EXTENSION);
        $src = Storage::disk(config('filesystems.default'))->readStream($m->path);
        $dst = fopen($path, 'w');
        stream_copy_to_stream($src, $dst);
        fclose($dst);
        return $path;
    }

    protected function saveTokens(SocialAccount $a, array $t): void
    {
        $a->update([
            'access_token' => $t['access_token'],
            'refresh_token' => $t['refresh_token'] ?? $a->refresh_token,
            'token_expires_at' => isset($t['expires_in']) ? now()->addSeconds((int) $t['expires_in']) : $a->token_expires_at,
        ]);
    }
}
X

app/Services/Publishers/XPublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\Media;
use App\Models\PostTarget;
use App\Models\SocialAccount;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class XPublisher extends BasePublisher
{
    private const API = 'https://api.x.com/2';

    public function discoverAccounts(string $t): array
    {
        $u = Http::withToken($t)->get(self::API . '/users/me')->throw()->json('data');
        return [['id' => $u['id'], 'name' => '@' . $u['username']]];
    }

    public function refreshToken(SocialAccount $a): void
    {
        $c = config('social.x');
        $r = Http::asForm()->withBasicAuth($c['client_id'], $c['client_secret'])->post($c['token_url'], [
            'grant_type' => 'refresh_token', 'refresh_token' => $a->refresh_token, 'client_id' => $c['client_id'],
        ])->throw();
        $this->saveTokens($a, $r->json()); // X rotates refresh tokens
    }

    public function publish(PostTarget $t): array
    {
        $tok = $this->token($t->socialAccount);
        $media = $this->media($t);
        $video = $media->firstWhere('type', 'video');
        $items = $video ? collect([$video]) : $media->where('type', 'image')->take(4);

        $ids = $items->map(fn (Media $m) => $m->type === 'video' ? $this->uploadVideo($tok, $m) : $this->uploadImage($tok, $m))->all();

        $body = ['text' => Str::limit($t->post->caption, 280, '')];
        if ($ids) $body['media'] = ['media_ids' => $ids];

        $id = Http::withToken($tok)->post(self::API . '/tweets', $body)->throw()->json('data.id');
        return ['id' => $id, 'url' => "https://x.com/i/status/$id"];
    }

    private function uploadImage(string $tok, Media $m): string
    {
        $f = $this->tmp($m);
        try {
            return Http::withToken($tok)->attach('media', file_get_contents($f), basename($f))
                ->post(self::API . '/media/upload', ['media_category' => 'tweet_image'])
                ->throw()->json('data.id');
        } finally { @unlink($f); }
    }

    private function uploadVideo(string $tok, Media $m): string
    {
        $f = $this->tmp($m);
        try {
            $size = filesize($f);
            $id = Http::withToken($tok)->post(self::API . '/media/upload/initialize', [
                'media_type' => 'video/mp4', 'total_bytes' => $size, 'media_category' => 'tweet_video',
            ])->throw()->json('data.id');

            $h = fopen($f, 'r'); $i = 0;
            while (!feof($h) && ($chunk = fread($h, 4 * 1024 * 1024)) !== false && $chunk !== '') {
                Http::withToken($tok)->timeout(120)->attach('media', $chunk, 'chunk')
                    ->post(self::API . "/media/upload/$id/append", ['segment_index' => $i++])->throw();
            }
            fclose($h);

            $fin = Http::withToken($tok)->post(self::API . "/media/upload/$id/finalize")->throw()->json('data');
            for ($n = 0; isset($fin['processing_info']) && $n < 40; $n++) {
                if (($fin['processing_info']['state'] ?? '') === 'succeeded') break;
                if (($fin['processing_info']['state'] ?? '') === 'failed') throw new \RuntimeException('X video processing failed.');
                sleep(max(2, (int) ($fin['processing_info']['check_after_secs'] ?? 3)));
                $fin = Http::withToken($tok)->get(self::API . '/media/upload', ['command' => 'STATUS', 'media_id' => $id])->throw()->json('data');
            }
            return $id;
        } finally { @unlink($f); }
    }
}
LinkedIn

app/Services/Publishers/LinkedInPublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\Media;
use App\Models\PostTarget;
use App\Models\SocialAccount;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class LinkedInPublisher extends BasePublisher
{
    private const API = 'https://api.linkedin.com/rest';

    private function li(string $tok): PendingRequest
    {
        return Http::withToken($tok)->withHeaders([
            'LinkedIn-Version' => config('social.linkedin.api_version'),
            'X-Restli-Protocol-Version' => '2.0.0',
        ]);
    }

    public function discoverAccounts(string $t): array
    {
        $u = Http::withToken($t)->get('https://api.linkedin.com/v2/userinfo')->throw()->json();
        return [['id' => 'urn:li:person:' . $u['sub'], 'name' => $u['name'] ?? 'LinkedIn member']];
        // Company pages: needs the Community Management API; list them via organizationAcls and add as extra accounts.
    }

    public function refreshToken(SocialAccount $a): void
    {
        if (!$a->refresh_token) throw new \RuntimeException('LinkedIn token expired; reconnect required.');
        $c = config('social.linkedin');
        $r = Http::asForm()->post($c['token_url'], [
            'grant_type' => 'refresh_token', 'refresh_token' => $a->refresh_token,
            'client_id' => $c['client_id'], 'client_secret' => $c['client_secret'],
        ])->throw();
        $this->saveTokens($a, $r->json());
    }

    public function publish(PostTarget $t): array
    {
        $a = $t->socialAccount;
        $tok = $this->token($a);
        $media = $this->media($t)->first();

        $body = [
            'author' => $a->external_id,
            'commentary' => $t->post->caption,
            'visibility' => 'PUBLIC',
            'distribution' => ['feedDistribution' => 'MAIN_FEED', 'targetEntities' => [], 'thirdPartyDistributionChannels' => []],
            'lifecycleState' => 'PUBLISHED',
            'isReshareDisabledByAuthor' => false,
        ];

        if ($media) {
            $urn = $media->type === 'video' ? $this->uploadVideo($tok, $a->external_id, $media)
                                            : $this->uploadImage($tok, $a->external_id, $media);
            $body['content'] = ['media' => ['id' => $urn]];
        }

        $res = $this->li($tok)->post(self::API . '/posts', $body)->throw();
        $urn = $res->header('x-restli-id');
        return ['id' => $urn, 'url' => "https://www.linkedin.com/feed/update/$urn"];
    }

    private function uploadImage(string $tok, string $owner, Media $m): string
    {
        $init = $this->li($tok)->post(self::API . '/images?action=initializeUpload', [
            'initializeUploadRequest' => ['owner' => $owner],
        ])->throw()->json('value');

        $f = $this->tmp($m);
        try {
            Http::withToken($tok)->withBody(file_get_contents($f), 'application/octet-stream')->put($init['uploadUrl'])->throw();
        } finally { @unlink($f); }
        return $init['image'];
    }

    private function uploadVideo(string $tok, string $owner, Media $m): string
    {
        $f = $this->tmp($m);
        try {
            $size = filesize($f);
            $init = $this->li($tok)->post(self::API . '/videos?action=initializeUpload', [
                'initializeUploadRequest' => ['owner' => $owner, 'fileSizeBytes' => $size, 'uploadCaptions' => false, 'uploadThumbnail' => false],
            ])->throw()->json('value');

            $h = fopen($f, 'r'); $etags = [];
            foreach ($init['uploadInstructions'] as $part) {
                fseek($h, $part['firstByte']);
                $chunk = fread($h, $part['lastByte'] - $part['firstByte'] + 1);
                $r = Http::withToken($tok)->timeout(300)->withBody($chunk, 'application/octet-stream')->put($part['uploadUrl'])->throw();
                $etags[] = $r->header('ETag');
            }
            fclose($h);

            $this->li($tok)->post(self::API . '/videos?action=finalizeUpload', [
                'finalizeUploadRequest' => ['video' => $init['video'], 'uploadToken' => $init['uploadToken'] ?? '', 'uploadedPartIds' => $etags],
            ])->throw();
            return $init['video'];
        } finally { @unlink($f); }
    }
}
TikTok

app/Services/Publishers/TikTokPublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\PostTarget;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class TikTokPublisher extends BasePublisher
{
    private const API = 'https://open.tiktokapis.com/v2';

    public function discoverAccounts(string $t): array
    {
        $u = Http::withToken($t)->get(self::API . '/user/info/', ['fields' => 'open_id,display_name'])->throw()->json('data.user');
        return [['id' => $u['open_id'], 'name' => $u['display_name'] ?? 'TikTok user']];
    }

    public function refreshToken(SocialAccount $a): void
    {
        $c = config('social.tiktok');
        $r = Http::asForm()->post($c['token_url'], [
            'client_key' => $c['client_id'], 'client_secret' => $c['client_secret'],
            'grant_type' => 'refresh_token', 'refresh_token' => $a->refresh_token,
        ])->throw();
        $this->saveTokens($a, $r->json());
    }

    public function publish(PostTarget $t): array
    {
        $a = $t->socialAccount;
        $tok = $this->token($a);
        $video = $this->media($t)->firstWhere('type', 'video');
        if (!$video) throw new \RuntimeException('TikTok publisher here supports video only.');

        // Unaudited apps are limited to private (SELF_ONLY) posts; use what the creator is allowed.
        $info = Http::withToken($tok)->post(self::API . '/post/publish/creator_info/query/')->throw()->json('data');
        $options = $info['privacy_level_options'] ?? ['SELF_ONLY'];
        $wanted = config('social.tiktok.privacy');
        $privacy = in_array($wanted, $options) ? $wanted : $options[0];

        $f = $this->tmp($video);
        try {
            $size = filesize($f);
            if ($size > 64 * 1024 * 1024) throw new \RuntimeException('Video over 64MB needs chunked upload (not implemented).');

            $init = Http::withToken($tok)->post(self::API . '/post/publish/video/init/', [
                'post_info' => [
                    'title' => Str::limit($t->post->caption, 2200, ''),
                    'privacy_level' => $privacy,
                ],
                'source_info' => [
                    'source' => 'FILE_UPLOAD', 'video_size' => $size, 'chunk_size' => $size, 'total_chunk_count' => 1,
                ],
            ])->throw()->json('data');

            Http::timeout(300)->withHeaders([
                'Content-Range' => 'bytes 0-' . ($size - 1) . "/$size",
            ])->withBody(file_get_contents($f), 'video/mp4')->put($init['upload_url'])->throw();
        } finally { @unlink($f); }

        $publishId = $init['publish_id'];
        for ($i = 0; $i < 30; $i++) {
            sleep(5);
            $s = Http::withToken($tok)->post(self::API . '/post/publish/status/fetch/', ['publish_id' => $publishId])->throw()->json('data');
            if (($s['status'] ?? '') === 'PUBLISH_COMPLETE') {
                return ['id' => $publishId, 'url' => null];
            }
            if (($s['status'] ?? '') === 'FAILED') throw new \RuntimeException('TikTok: ' . ($s['fail_reason'] ?? 'failed'));
        }
        return ['id' => $publishId, 'url' => null]; // still processing; accepted by TikTok
    }
}
YouTube

app/Services/Publishers/YouTubePublisher.php

php
<?php
namespace App\Services\Publishers;

use App\Models\PostTarget;
use App\Models\SocialAccount;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class YouTubePublisher extends BasePublisher
{
    public function discoverAccounts(string $t): array
    {
        $items = Http::withToken($t)->get('https://www.googleapis.com/youtube/v3/channels', ['part' => 'snippet', 'mine' => 'true'])
            ->throw()->json('items', []);
        return array_map(fn ($c) => ['id' => $c['id'], 'name' => $c['snippet']['title']], $items);
    }

    public function refreshToken(SocialAccount $a): void
    {
        $c = config('social.youtube');
        $r = Http::asForm()->post($c['token_url'], [
            'client_id' => $c['client_id'], 'client_secret' => $c['client_secret'],
            'refresh_token' => $a->refresh_token, 'grant_type' => 'refresh_token',
        ])->throw();
        $this->saveTokens($a, $r->json());
    }

    public function publish(PostTarget $t): array
    {
        $tok = $this->token($t->socialAccount);
        $video = $this->media($t)->firstWhere('type', 'video');
        if (!$video) throw new \RuntimeException('YouTube requires a video.');

        $post = $t->post;
        $title = $post->title ?: Str::limit(strtok($post->caption, "\n"), 100, '');
        $f = $this->tmp($video);

        try {
            $size = filesize($f);
            $init = Http::withToken($tok)->withHeaders([
                'X-Upload-Content-Type' => 'video/mp4',
                'X-Upload-Content-Length' => $size,
            ])->post('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', [
                'snippet' => ['title' => $title, 'description' => $post->caption],
                'status' => [
                    'privacyStatus' => config('social.youtube.privacy'),
                    'selfDeclaredMadeForKids' => false,
                ],
            ])->throw();

            $res = Http::withToken($tok)->timeout(0)
                ->withBody(Utils::streamFor(fopen($f, 'r')), 'video/mp4')
                ->put($init->header('Location'))->throw();

            $id = $res->json('id');
            return ['id' => $id, 'url' => "https://www.youtube.com/watch?v=$id"];
        } finally { @unlink($f); }
    }
}

A vertical video of 3 minutes or less with #Shorts in the description is treated as a Short.

PublisherFactory (updated):

php
return match ($platform) {
    'facebook'  => app(FacebookPublisher::class),
    'instagram' => app(InstagramPublisher::class),
    'x'         => app(XPublisher::class),
    'linkedin'  => app(LinkedInPublisher::class),
    'tiktok'    => app(TikTokPublisher::class),
    'youtube'   => app(YouTubePublisher::class),
    default     => throw new \InvalidArgumentException("No publisher for {$platform}"),
};

Add a per-platform validation in PostController::store before saving. It would reject a YouTube or TikTok target with no video, an Instagram target with no media, and an X caption over 280 characters. It saves companies from failures at publish time.

7. .env additions
# Free-tier-friendly AI
POLLINATIONS_API_KEY=
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
HF_TOKEN=
TOGETHER_API_KEY=
TOGETHER_IMAGE_MODEL=black-forest-labs/FLUX.1-schnell   # check Together's model list for the current free variant
GEMINI_API_KEY=
GEMINI_IMAGE_MODEL=
GEMINI_TEXT_MODEL=
GROQ_API_KEY=
GROQ_TEXT_MODEL=
OPENROUTER_API_KEY=
OPENROUTER_TEXT_MODEL=
FAL_KEY=
FAL_VIDEO_MODEL=
REPLICATE_API_TOKEN=
REPLICATE_VIDEO_MODEL=

# Social apps (one app per platform, owned by you, used by all companies)
X_CLIENT_ID=
X_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
LINKEDIN_API_VERSION=202506
TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=
TIKTOK_PRIVACY=PUBLIC_TO_EVERYONE
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
YOUTUBE_PRIVACY=public
8. Platform gotchas (these decide your launch timeline)
Platform	Needs	Watch out for
X	Developer app, OAuth 2.0 with PKCE	API pricing and free-tier posting limits have changed repeatedly, so check the portal. Refresh tokens rotate.
LinkedIn	“Share on LinkedIn” product for member posts	Company Page posting needs Community Management API approval. Tokens last about 60 days and refresh tokens are only granted to some apps, so expect reconnect prompts. LinkedIn-Version values get retired, so update LINKEDIN_API_VERSION periodically.
TikTok	App review for Content Posting API	Until your app is audited, posts are forced private. Account must be allowed by creator-info.
YouTube	Google OAuth consent screen, YouTube Data API	Default quota is 10,000 units/day and an upload costs about 1,600 (roughly 6 uploads/day across all companies) until you request more. Unverified API projects have uploads locked to private.
Meta	App Review	Same as before.