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

Matinee Laravel Package

awcodes/matinee

Matinée is an OEmbed and video field for Filament panels and form builders. It stores video data as JSON, supports YouTube and Vimeo out of the box, allows custom providers, and can force a preview to display.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Confirm your Filament major first. This drives everything else:

Filament Matinee
3.x 1.x (legacy line)
4.x or 5.x 3.x (recommended — one line spans both 4 and 5)

If you are on Filament 3, treat Matinee 1.x as a frozen dependency and expect to migrate the field when you upgrade Filament.

2. Install and set up the theme. The custom theme is not optional — it is a prerequisite task, not a footnote.

composer require awcodes/matinee

Create/point at a Filament custom theme (per the Filament styling docs), then add the package views to Tailwind's source scan in your theme CSS:

@source '../../../../vendor/awcodes/matinee/resources/**/*.blade.php';

Skip this and the field renders structurally but unstyled, which is the single most common "it's broken" report for this package. Verify the relative path depth against where your theme CSS actually lives.

3. Prepare the column. Matinee stores one JSON blob, so the cast is mandatory:

// migration
$table->json('video')->nullable();

// model
protected $casts = [
    'video' => 'array',
];

4. Drop in the field and render. The field, and the shipped Blade component:

use Awcodes\Matinee\Matinee;

Matinee::make('video')
    ->label('Promo video')
    ->showPreview()   // force the preview iframe in the form UI
@if ($record->video)
    <x-matinee::embed :data="$record->video" />
@endif

First use case to spike: a nullable video column on a content model (Post, Product, Course lesson), authored in the panel, rendered on the public show page. That exercises the whole contract — cast, field, provider resolution, JSON schema, Blade component — in about half a day. Where to look first: dump one saved row (dd($record->video)) before writing any front-end code. The stored shape is the real API you're integrating against:

{
  "width": "16",
  "height": "9",
  "responsive": true,
  "url": "https://www.youtube.com/watch?v=N9qZFD1NkhI",
  "embed_url": "https://www.youtube.com/embed/N9qZFD1NkhI?controls=1&start=0",
  "options": { "controls": "1", "nocookie": "0", "start": "00:00:00" }
}

Implementation Patterns

Centralize a field factory. You will use this field in more than one resource, and provider lists drift. Wrap it in one static factory so every form gets identical defaults:

namespace App\Filament\Fields;

use Awcodes\Matinee\Matinee;

class VideoField
{
    public static function make(string $name = 'video'): Matinee
    {
        return Matinee::make($name)
            ->providers([\App\Providers\LoomProvider::class])
            ->showPreview();
    }
}

Set global defaults in a service provider. Instead of repeating modifiers per field, configure once with configureUsing() in register()/boot():

use Awcodes\Matinee\Matinee;

Matinee::configureUsing(function (Matinee $field) {
    $field->providers([LoomProvider::class]);
});

Author a custom provider per embedded service. Any host outside YouTube/Vimeo (Loom, Wistia, Brightcove, Mux, Vimeo variants, internal players) is a class implementing MatineeProvider with the IsMatineeProvider trait:

class LoomProvider implements MatineeProvider
{
    use IsMatineeProvider;

    public function getId(): ?string { return 'loom'; }

    public function getDomains(): array
    {
        return ['loom.com', 'www.loom.com'];
    }

    public function getOptions(): array
    {
        return ['hide_owner' => 1, 'hide_shared_brand' => 1];
    }

    public function convertUrl(?array $options = []): string
    {
        // parse the video id out of $this->getUrl() / your own URL property
        return 'https://www.loom.com/embed/' . $videoId . '?' . http_build_query($options);
    }
}

Treat the stored JSON as an internal contract and normalize it. Do not let Blade templates reach into ['options']['nocookie'] directly. Add one accessor or small DTO on the model so a schema change in a future minor release is a one-file fix, and so API resources / Inertia props / Livewire views all read the same shape.

Render through your own partial, even if it delegates to the package component. This gives you one place for null guards, a responsive wrapper, loading="lazy", and any consent gating:

@props(['video' => null])

@if ($video && ! empty($video['embed_url']))
    <div class="aspect-video">
        <x-matinee::embed :data="$video" />
    </div>
@else
    <div class="aspect-video bg-gray-100"></div>
@endif

Consent / click-to-load pattern. Since embed_url and options are both persisted, you can build a GDPR-friendly path without touching the field: render a static poster plus a button, and only emit the iframe (or swap to options.nocookie) on interaction. Keep that decision in your renderer, not in the field.

Validation belongs to you. The field captures a URL; it does not guarantee the host is one you support. Pair it with ->url() and, if only certain hosts are allowed, a Rule::in on the resolved provider or a custom closure rule — cheaper than discovering malformed rows later.

Test at the model/component level, not the Livewire field. A Pest test that saves a known YouTube and Vimeo URL and asserts embed_url and dimension keys is fast, deterministic, and catches the provider-level regressions this package has actually shipped fixes for.

Gotchas and Tips

Missing @source = unstyled field. Nothing errors; it just looks wrong. Check this before filing anything.

Without the array cast you get a JSON string, and <x-matinee::embed> expects array-shaped data. The failure mode is confusing rather than loud.

Snapshot semantics. embed_url is computed and persisted at save time. Changing a provider's getOptions(), adding a domain, or fixing a convertUrl() bug does not rewrite existing rows — you need a backfill (re-save, or a one-off command that re-runs provider conversion) and you need to decide whether old and new rows may legitimately differ. The flip side is a stability win: your public pages never depend on provider logic at request time.

The custom-provider doc example is subtly wrong. In the README, convertUrl() returns .../embed/ . $this->getId(), but getId() is the provider slug ('custom'), so the URL becomes /embed/custom?.... You must parse the actual video id out of the URL yourself inside convertUrl(); the trait does not hand you one. Expect to write small URL-parsing helpers per provider and test them with trailing slashes, query strings, and short-link forms.

Register every host variant. getDomains() matching is literal — include www. and non-www., m. mobile hosts, and short-link domains. A user pasting a short URL that isn't listed will fall through to no provider.

Width/height semantics are ambiguous in the docs — verify empirically. The documented payload shows "width": "16", "height": "9" (a ratio), while the v3.1.2 changelog talks about fixed-size embeds rendering at "stored pixel dimensions." Dump real rows for both responsive and non-responsive saves before you write CSS that depends on those keys. Since v3.1.2, missing width/height falls back to 16:9 instead of warning; older versions emitted undefined-key warnings and rendered at size 0.

Fixed-size embeds were broken until v3.1.2 in two ways: the responsive toggle reset itself on edit (so saving silently flipped a fixed embed back to responsive), and the Blade component stretched every iframe to full width. If you depend on fixed-size embeds, pin >=3.1.2.

PHP floor matters. v3.1.1 fixed an mb_trim() call that only exists in PHP 8.4 against a declared ^8.2 constraint — it can fatal on 8.2/8.3 if the polyfill isn't pulled in. Keep your PHP constraint explicit and don't assume the package's declared floor is exercised on every version.

Very small social proof: you are the canary. 22 stars, 0 dependents, single maintainer, MIT. Practical mitigations: pin exact versions, read patch-release diffs before bumping (they're short), and keep the render path behind your own partial so you can replace <x-matinee::embed> with a hand-written iframe from embed_url in minutes if you ever need to fork or drop the package. The stored JSON is the only real lock-in, and it's yours.

Guard for degraded data. A null provider used to throw (fixed in v1.0.1), and stored data can be partial or hand-edited. Always ?? null the embed_url key at render time.

Label handling. Custom label() was ignored until v3.1.1 — on older tags, use the field name or a wrapper. On 3.1.1+ it behaves like any Filament field.

Don't oversell it internally. No thumbnails, no transcoding, no local storage, no oEmbed fetch at render, no table column shipped. If stakeholders hear "video field," they may assume a media library. Scope it as "a URL capture that persists a normalized embed payload," and put any thumbnail/poster strategy in your own layer.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core