Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Devtube Laravel Package

devswebdev/devtube

DevTube is a Laravel wrapper around the yt-dlp binary for downloading videos and extracting audio from any URL. Pass a link, pick a format like mp4 or mp3, and get back a collection of MediaFile objects. Requires PHP 8.3+, Laravel 12/13, and yt-dlp.

View on GitHub
Deep Wiki
Context7

Getting Started

The package is a wrapper; yt-dlp is the real dependency. Nothing in this package does any network work itself — it shells out to the yt-dlp binary and returns a DTO. Install and verify that first, before you touch Composer:

python3 -m pip install -U yt-dlp
sudo apt install ffmpeg      # only needed for mp3 / audio extraction
which yt-dlp                 # copy this absolute path
yt-dlp --version             # note it; you will pin/upgrade this later

Install and publish the config.

composer require devswebdev/devtube

php artisan vendor:publish \
  --provider="DevsWebDev\DevTube\DevTubeServiceProvider" --tag=config

Provider and the DevTube facade alias are auto-discovered via package discovery — there is nothing to add to bootstrap/providers.php or config/app.php.

Set the two values you will actually change, in .env rather than in the published file:

DEVTUBE_YT_DLP_PATH=/usr/local/bin/yt-dlp
DEVTUBE_DOWNLOAD_PATH=app/devtube

bin_path wants an absolute path. download_path is relative to storage_path(), so the default resolves to storage/app/devtube.

Smoke-test from the CLI before writing a single line of PHP. The bundled Artisan command is the fastest way to separate "environment problem" from "package problem":

php artisan devtube:download "https://www.youtube.com/watch?v=ye5BuYf8q4o" --format=mp4

It renders a result table and exits non-zero on any failure, so it works in a && chain or a CI step. If this fails, stop — your problem is the binary, permissions, or the config path, and no amount of PHP debugging will help.

First real use case: an admin-only "save this URL" action.

use DevsWebDev\DevTube\Facades\DevTube;

$media = DevTube::download($request->url, 'mp4')->first();

abort_if($media === null || ! $media->wasSuccessful(), 422, $media?->error ?? 'No media returned');

return response()->download($media->path());

Where to look when it breaks, in this order: config/devtube.php (did you get the v3 shape, or a stale 2.x file?) → which yt-dlp executed as the php-fpm/queue user, not your shell user → the DownloadException in storage/logs/laravel.log → the same URL through php artisan devtube:download to reproduce outside your app code.

Implementation Patterns

Never call download() inside a request cycle. It is a blocking subprocess with no progress callback, no async, no timeout management. A long video will exceed max_execution_time and hold a php-fpm worker hostage. The package gives you no job — that is your layer to write:

final class DownloadMediaJob implements ShouldQueue
{
    public int $timeout = 1800;
    public int $tries  = 3;

    public function __construct(
        public int $mediaRequestId,
        public string $url,
        public string $format = 'mp4',
    ) {}

    public function handle(): void
    {
        $record = MediaRequest::findOrFail($this->mediaRequestId);

        try {
            $results = DevTube::download(
                $this->url,
                $this->format,
                storage_path("app/devtube/{$record->id}"),
            );
        } catch (DownloadException $e) {
            // Hard failure: missing binary, unwritable dir, process died.
            $record->markFailed($e->getMessage());
            $this->fail($e);
            return;
        }

        $media = $results->first();

        if ($media === null || ! $media->wasSuccessful()) {
            // Soft failure: video removed, geo-blocked, playlist item gone.
            $record->markFailed($media?->error ?? 'No media returned');
            return;   // do not retry — it will fail identically
        }

        $record->attach($media->path(), $media->title);
    }
}

The hard/soft split is the single most useful operational detail in the package: a thrown DownloadException is often transient and worth retrying; a MediaFile carrying an error is usually permanent and retrying just burns the queue and hammers the remote site.

Use the third argument as your per-download isolation boundary. The default directory is one flat shared folder, and output_template is %(title)s.%(ext)s — two videos with the same title silently overwrite each other under concurrency. Passing an absolute per-job directory removes the collision and makes cleanup a one-liner:

Storage::disk('local')->deleteDirectory("devtube/{$record->id}");

Drive format policy through config, not call sites. Add your own keys to config('devtube.formats') and pass the key as $format. Format selection then lives in one reviewable file:

'formats' => [
    'mp4'   => ['format' => 'mp4'],
    'mp3'   => ['extract_audio' => true, 'audio_format' => 'mp3', 'audio_quality' => '0'],
    '720p'  => ['format' => 'bestvideo[height<=720]+bestaudio/best'],
    'audio' => ['extract_audio' => true, 'audio_format' => 'm4a'],
],

Wrap the facade behind your own interface. The public surface is tiny — one facade, one Download class, one MediaFile DTO, one exception. That makes a 20-line abstraction cheap, and worth it for two reasons: v3.0 was a genuinely breaking release (arrays → objects, config rewritten, engines swapped), and this is a 36-star single-maintainer package. You want a one-file swap to a fork or a managed API when the day comes.

interface MediaDownloader
{
    public function fetch(string $url, string $format, string $directory): DownloadResult;
}

It also gives you a seam for tests: bind a fake implementation and assert your job's state transitions (success / soft failure / hard failure) rather than hitting YouTube in CI.

Use the Download class where there is no container — standalone scripts, one-off Artisan commands, Pest tests. It's a plain constructor with named arguments:

$results = (new Download(url: $url, format: 'mp3'))->download();

Use devtube:download for cron, backfills and health checks. Because it exits non-zero on any failure, it composes with shell: a while read url backfill from a URL list, a nightly "is the binary still alive" heartbeat, or a container readiness probe. Its --path flag lets ops drop output onto a mounted volume without a code deploy.

Iterate and assert per item, never on the collection. A playlist or multi-result extraction yields several MediaFile objects with mixed outcomes:

foreach ($results as $media) {
    $media->wasSuccessful()
        ? $this->store($media->path(), $media->title)
        : Log::warning('devtube.item_failed', ['error' => $media->error]);
}

first() on an empty collection returns null — guard it or you'll trade a handled failure for a fatal error.

Gotchas and Tips

PATH differs between your shell, php-fpm, and the queue worker. This is the number-one cause of "it works on the CLI but not in the app." Always set DEVTUBE_YT_DLP_PATH to an absolute path. Diagnose with sudo -u www-data which yt-dlp, not which yt-dlp.

yt-dlp rots fast. Extractors break within weeks of site changes, so an image built six months ago will start throwing hard failures on URLs that used to work. Pin the version in your Dockerfile for reproducibility, but budget a regular bump — and make the version visible somewhere (yt-dlp --version in a health check) so a break is obvious rather than mysterious. Also remember the binary must be executable and readable by the web/worker user.

ffmpeg is only needed for audio. mp4 works without it; mp3 and any extract_audio preset will hard-fail without it. If audio is a feature, ship ffmpeg in the image — don't discover it in production.

download_path is relative to storage_path(), not the filesystem root. Setting DEVTUBE_DOWNLOAD_PATH=/var/downloads gives you storage_path('/var/downloads'). Use the third argument on download() (or --path on the command) for absolute locations.

Run php artisan config:clear after upgrading to 3.0. The config file was rewritten with new keys (bin_path, download_path, default_format, output_template, formats) and the old keys were removed. If you have a stale cached config, bin_path will be missing entirely and the failure will look like a binary problem.

2.x code will not work, and it fails loudly at access time. download() returns a Collection of objects now:

// 2.x
response()->download($dl->download()->first()['file']->getPathname());

// 3.0
$media = $dl->download()->first();
response()->download($media->path());

Use path() rather than reaching through $media->file — file is a nullable SplFileInfo and is null on failure.

Nothing is cleaned up for you. Files accumulate under storage/app/devtube indefinitely. Pair every download with a delete-on-send, a pruning job, or per-request directories reaped by Storage::deleteDirectory(). On a long-lived box this is a disk-full incident waiting to happen.

response()->download() streams the file but leaves it on disk, so a "user downloads a video" button will grow storage/ forever unless you ->deleteFileAfterSend(true) or clean up out-of-band.

Don't over-trust the mp4 preset on modern YouTube. 'format' => 'mp4' is a container hint, not a resolution request; you may get a lower-quality merged stream or audio-only depending on what the site exposes. If quality matters, add explicit presets like bestvideo[height<=1080]+bestaudio/best and pick them by key — and note that merging streams requires ffmpeg too.

Treat this as a compliance surface, not just a technical one. Downloading third-party content can violate a site's ToS and local law. Keep it behind admin auth, log who requested what, and don't expose it as a public "paste any URL" endpoint.

Extension point that isn't there: there is no per-download option passthrough beyond the formats config, no progress events, and no retry/backoff. If you need any of that, subclass or wrap Download in your own service — which, again, is why the interface wrapper is worth writing on day one.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo