Easily Deduplicate Jobs Using Laravel Queues

TL:DR; use a delayed dispatch plus the ShouldBeUnique contract on your job, and let the framework deduplicate it for you!

Picture this scenario: you receive webhooks from a third-party service that you need to handle somehow in your application. Due to multiple changes at the third party, you may receive multiple webhooks about the same resource within a short timeframe (2 minutes, for example), but you only want to process it a single time.

You could add a processed_at timestamp and track when you last processed the resource, and bypass additional processing using that.

Or you could combine several tools the Laravel framework already provides:

  1. Determine the expected timeframe for multiple updates
    • e.g., if somebody is manually updating multiple fields and you get a webhook for each change, estimate how long a user might be working before you want to process the changes
  2. Add a delay when you dispatch the job to cover that expected timeframe, plus a little bit extra: SomeJob::dispatch($data)->delay(now()->addMinutes(3))
  3. Add the ShouldBeUnique interface to your job
  4. Add the uniqueId() method to your job with a unique ID or some other key that will be used to find a match
  5. Voila!

Now when you receive an incoming webhook, your app will delay the processing for 3 minutes, and if there is already a job on the queue for that unique ID, the framework will not dispatch a second job.

One additional thing to consider: within that job, you may wish to perform an API call to retrieve the current state of the resource, since the dispatched job will contain the state as of the first webhook, not the most recent.

<?php

// somewhere in your app

SomeJob::dispatch($data)->delay(
    now()->addMinutes(3)
);
<?php
 
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
 
class SomeJob implements ShouldQueue, ShouldBeUnique
{
    /**
     * Get the unique ID for the job.
     */
    public function uniqueId(): string
    {
        return $this->data->id;
    }

    ...
}

Fatal Errors with PHPUnit Test Suite: Laravel 10, PHPUnit 10, ParaTest 7

TL;DR: PHPUnit 10.5.32 is broken, 10.5.31 works.

After a composer update one one of my projects today, I started getting this error when running my test suite: Fatal error: Uncaught Illuminate\Contracts\Container\BindingResolutionException: Target [Illuminate\Contracts\Debug\ExceptionHandler] is not instantiable.

All the tests passed just fine…no errors or warnings. At the end of the test execution output, it displayed this stacktrace:

> php artisan test --parallel --stop-on-failure --stop-on-error
ParaTest v7.4.5 upon PHPUnit 10.5.32 by Sebastian Bergmann and contributors.

Processes:     10
Runtime:       PHP 8.3.10
Configuration: /Users/andrewminion/Sites/site-domain/phpunit.xml

.......................................................SS....   61 / 1014 (  6%)
.............................................................  122 / 1014 ( 12%)
.............................................................  183 / 1014 ( 18%)
.............................................................  244 / 1014 ( 24%)
.............................................................  305 / 1014 ( 30%)
.............................................................  366 / 1014 ( 36%)
..................................S...I......................  427 / 1014 ( 42%)
.............................................................  488 / 1014 ( 48%)
.............................................................  549 / 1014 ( 54%)
.............................................................  610 / 1014 ( 60%)
.............................................................  671 / 1014 ( 66%)
.............................................................  732 / 1014 ( 72%)
.............................................................  793 / 1014 ( 78%)
.............................................................  854 / 1014 ( 84%)
.............................................................  915 / 1014 ( 90%)
.........................I...................................  976 / 1014 ( 96%)
......................................                        1014 / 1014 (100%)

Time: 02:04.815, Memory: 70.50 MB

OK, but there were issues!
Tests: 1014, Assertions: 6462, Skipped: 3, Incomplete: 2.

Fatal error: Uncaught Illuminate\Contracts\Container\BindingResolutionException: Target [Illuminate\Contracts\Debug\ExceptionHandler] is not instantiable. in /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php on line 1126

Illuminate\Contracts\Container\BindingResolutionException: Target [Illuminate\Contracts\Debug\ExceptionHandler] is not instantiable. in /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php on line 1126

Call Stack:
  125.2423   77506896   1. Illuminate\Foundation\Bootstrap\HandleExceptions->Illuminate\Foundation\Bootstrap\{closure:/Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:254-256}() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:0
  125.2423   77507336   2. Illuminate\Foundation\Bootstrap\HandleExceptions->handleException() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:255
  125.2423   77470472   3. Illuminate\Foundation\Bootstrap\HandleExceptions->getExceptionHandler() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:183
  125.2423   77470472   4. Illuminate\Foundation\Application->make() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:288
  125.2423   77470472   5. Illuminate\Foundation\Application->make() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:946
  125.2423   77470472   6. Illuminate\Foundation\Application->resolve() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:731
  125.2423   77470472   7. Illuminate\Foundation\Application->resolve() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:961
  125.2423   77470528   8. Illuminate\Foundation\Application->build() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:795
  125.2423   77470624   9. Illuminate\Foundation\Application->notInstantiable() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:921


Fatal error: Uncaught Illuminate\Contracts\Container\BindingResolutionException: Target [Illuminate\Contracts\Debug\ExceptionHandler] is not instantiable. in /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php on line 1126

Illuminate\Contracts\Container\BindingResolutionException: Target [Illuminate\Contracts\Debug\ExceptionHandler] is not instantiable. in /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php on line 1126

Call Stack:
  125.2457   77507224   1. Illuminate\Foundation\Bootstrap\HandleExceptions->Illuminate\Foundation\Bootstrap\{closure:/Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:254-256}() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:0
  125.2457   77507448   2. Illuminate\Foundation\Bootstrap\HandleExceptions->handleShutdown() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:255
  125.2460   77519232   3. Illuminate\Foundation\Bootstrap\HandleExceptions->handleException() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:231
  125.2460   77519232   4. Illuminate\Foundation\Bootstrap\HandleExceptions->getExceptionHandler() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:183
  125.2460   77519232   5. Illuminate\Foundation\Application->make() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:288
  125.2460   77519232   6. Illuminate\Foundation\Application->make() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:946
  125.2460   77519232   7. Illuminate\Foundation\Application->resolve() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:731
  125.2460   77519232   8. Illuminate\Foundation\Application->resolve() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Foundation/Application.php:961
  125.2460   77519232   9. Illuminate\Foundation\Application->build() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:795
  125.2460   77519328  10. Illuminate\Foundation\Application->notInstantiable() /Users/andrewminion/Sites/site-domain/vendor/laravel/framework/src/Illuminate/Container/Container.php:921

After some trial-and-error, I narrowed it down to PHPUnit 10.5.32. Something in the changeset from 10.5.31 to 10.5.32 caused this error.

This also happens only when running tests in parallel (php artisan test --parallel), suggesting to me that perhaps the PHPUnit output changed in some way that ParaTest was not expecting.

My fix, at least for now, is to stick with PHPUnit 10.5.31, and hopefully an imminent upgrade to Laravel 11 and PHPUnit 11 will resolve it.

Laravel Testing Tip: Reset UUID Creation

TL;DR: if you call Str::createUuidsUsing(…) in a test method, don’t forget to call Str::createUuidsNormally() later in that same method (or the tearDown method), or the rest of your test suite will continue to use that same UUID.

Laravel’s string helper provides a nice interface for generating uuids, as well as a nice way to fake the UUIDs during a test:

While this is very useful, I expected that it would reset between tests, similar to Queue::fake(), Http::fake(), etc.

However, because of how the Str helper generates UUIDs, whatever you provide will be used for the rest of the test run. If you have other tests or app code that expects a unique UUID each time Str::uuid() is used, you may get unexpected results.

There are a couple of options to work around this:

  1. After running the code that needs a UUID, call Str::createUuidsNormally() to reset the Str helper.
  2. If you don’t actually need the value of the UUID for testing, you can wrap your code in the freezeUuids() method instead. Once your code in the callback finishes running, the framework will call createUuidsNormally() to reset everything for you:

Laravel Tip: Generating Signed URLs with Ignored Parameters

TL;DR: don’t use ignored URL parameters when building signed URLs or the resulting signed URL will be invalid. Instead, manually append them to the resulting URL.

Laravel includes some really nice helpers for building signed URLs: https://laravel.com/docs/master/urls#signed-urls

They allow you to generate a URL containing a signature that prevents anybody from modifying the URL to access something you didn’t intend (e.g., you could provide a signed URL for a specific post with ID 123; if somebody changed that ID to 124, then Laravel will display a 403 Signature Invalid error rather than happily displaying post 124).

Occasionally you may wish to ignore certain URL parameters when validating the signature (e.g., a pagination or print parameter).

In this case, you cannot include the ignored parameter when generating the signed URL, or the URL will be invalid.

Here’s an example. This route ignores the print parameter when verifying the signature:

If you generate a signed URL without the print parameter, it will be valid. But if you include print in the URL parameters for the helper method, the resulting signature will be invalid, because Laravel uses all of those parameters to generate the signature. Instead, just add the new parameter to the end of the resulting URL:

Note how examples 1 and 3 have the same signature; that is the signature that Laravel calculates when determining what the correct signature should be to verify that the URL has not been modified. The example 2 use print=true when generating the signature, but will remove that parameter when verifying the signature, so they don’t match.

Update: I submitted a PR to the framework to pass ignored parameters to the signed route methods to make this easier.

BarTender 5 Trigger for VPN Status

BarTender is a wonderful little macOS app that you can use to hide menubar apps that you don’t need to see very often.

I also use the Viscosity VPN client and have Bartender set to hide the Viscosity icon, but I would love it to be visible if the VPN is active.

This should also work for any other VPN client that uses menu bar item, not just Viscosity.

Thanks to a new BarTender feature named “Triggers,” this is possible:

  1. Set the Viscosity (or your VPN app) to be hidden all the time
  2. Create a new trigger
  3. Set it to show Viscosity (or your VPN app) based on a “script condition”
  4. Add this to the script contents, replacing x.x.x.x on the first line 1with the IP address of your VPN connection:
  5. Save and close

For reference, here’s a screenshot showing the trigger:

BarTender 5 script trigger to show Viscosity VPN menubar icon when VPN is active
Don’t forget to replace x.x.x.x with your VPN’s actual IP address

Viscosity With Multiple VPNs

If you use Viscosity and have multiple possible VPN connections, this script may work better for you:

It uses AppleScript to check the status of all Viscosity connections and should cause the menu icon to display if any of them are active, rather than looking for a specific IP address.

Bonus: AppleScript App to Toggle a Specific Viscosity Connection

Bonus: update line one with the name of your Viscosity connection, and save this script with the “Application” file format. Then you can use it in Raycast, Alfred, Spotlight, etc. to toggle one specific connection on and off:

Debugging HTTP Client Request Assertions in Laravel Test Suites

The Http::assertSent(), Event::assertDispatched(), and Queue::assertPushed()) test methods are perhaps a bit unintuitive.

They seem like they would run just once and check the assertions provided in your callback.

In fact, that callback runs once for each HTTP request (or dispatched event or queued job) and it evaluates the logic inside the callback for each. So if you have 10 requests (or events or jobs) and 3 of them return true it passes. If you have 10 requests and 9 of them return true it passes. It only fails if none of the callbacks return true.

So using dd($request) in one of those assertions is only dumping out the first one and then killing the test. You might not see the one you actually need.

If you’re trying to see what data is in the request, you’re better off either doing something like Log::debug($request->body()) or ray($request->url())1 or putting an xdebug breakpoint on the first line of the callback, running the test, and pressing the “continue” button until you get to the request you’re trying to inspect (possibly the second or fourth or tenth, depending on what happens before the one you want to inspect).

Here is a few code samples that may clarify this a bit more:

Proxying Signed AWS S3 URLs using CloudFlare Workers

A common use case for S3 is hosting content that should not be available to the public, but needs to be made available to specific user(s) or for a specific length of time. A great example of this is granting access to digital files after a purchase or subscription payment.

In this case, I needed the domain to be a first-party subdomain, rather than a default Amazon AWS domain, due to same-origin policy requirements.

Hat-tip to Fershad Irani for an initial version, which I modified to suit my needs.

Set up the AWS Bucket

  1. Create a bucket
  2. Prevent all public access to objects in the bucket
  3. Upload files

Configure a Cloudflare Worker

  1. Go to CloudFlare > Workers & Pages > Overview and create a new application
  2. Add the worker code below, modifying line 8 to use your bucket name
  3. Publish the worker
  4. If you already added the subdomain under the DNS tab pointing to anywhere, delete that before proceeding
  5. View the worker and go to the Triggers tab
  6. Under Custom Domains, add a custom domain (documentation) and enter your custom subdomain
  7. Under Routes, add a route for your custom subdomain

My Favorite VS Code Extensions

In a given day, I tend to work primarily on Laravel apps, some using Livewire and some with Inertia.js and a Vue.js frontend, as well as a smattering of WordPress sites and/or custom plugins.

I’ve tried PhpStorm and didn’t care for it, so like what feels like 90% of the rest of the industry, I use VS Code as my primary editor.

Here’s a list of the extensions I use on a daily basis:

General

Sublime Text Keymap and Settings Importer: I used Sublime Text for a year or so and built up muscle memory for the keyboard shortcuts, so these make a lot more intuitive sense to me than the standard VS Code keyboard shortcuts.

Markdown Preview Mermaid Support: I like to document solution architecture using mermaid diagrams, and this is a great extension to preview these in VS code.

Path Intellisense provides autocompletion when typing relative file paths in a project.

EditorConfig for VS Code configures some editor settings for different projects based on the configuration stored in the project.

Prettier – Code formatter is useful for automatically formatting code files.

TODO Highlight v2 provides visual feedback for TODO/FIXME/etc. comments in code.

Encode Decode is extremely useful when dealing with encoded strings. I use it fairly frequently to decode base64-encoded strings.

Remote – SSH / Remote – SSH: Editing Configuration Files / Remote Explorer make it really easy to “cowboy code” on a server 😬 and are useful for occasional debugging in production.

Live Share is amazing for pair-programming: it allows you to open the same codebase your colleague is working on and work with it on your machine just as if it were a local project.

Partial Diff is useful when comparing two files to find minor differences (e.g., when debugging API requests or responses).

PHP

Composer Intelephense shows you the actually-installed version of each package in your composer.json file, and gives you a quick link to the packagist.org page for each.

PHP DocBlocker reduces some of the boilerplate necessary when writing docblocks.

PHP Intelephense in my opinion is significantly better than the built-in PHP language support, providing autocompletion for functions, methods, variables, etc., project-wide parameter hints, and even some static analysis features.

PHPUnit Test Workbench is a very useful wrapper for phpunit; you can run a single test, a single file, or the entire test suite, and it shows a list of all of the tests in the sidebar. I use it frequently to run my entire test suite to see a quick list of which job(s) failed. (I used to use PHPUnit Test Explorer along with Test Explorer UI and Test Adapter Converter)

phpstan runs static analysis on files as I save them, showing my errors as I write code.

PHP Debug might just be the extension I interact with the most; it lets me set breakpoints, step through, and inspect code as it runs. I can’t imagine trying to program without it.

Laravel

I’m eagerly awaiting the release of the first-party Laravel extension, as I expect it to replace several of the following plugins; but for now, I use these:

DotEnv provides syntax highlighting for .env files used for Laravel configuration.

Laravel Extra Intellisense saves me a lot of time by auto-completing routes names and parameters, configuration keys, views and variables, validation rules, and more.

Laravel Blade Spacer automatically adds spaces when you add a new curly brace pair: just a minor code style convenience.

Livewire Language Support provides autocompletion and other features for Livewire projects.

Laravel goto view provides one-click access to views from controllers.

Laravel Blade Snippets provides Blade snippets and syntax highlighting.

Laravel Pint provides automatic code formatting using Pint.

Laravel Blade formatter provides formatting tools for Blade templates.

Flux UI provides autocomplete for Livewire Flux components.

Javascript

Alpine.js IntelliSense provides intellisense and snippets for alpine.js.

Inertia.js provides support for linking to vue templates and autocompletes component names.

Other Languages

GraphQL: Syntax Highlighting provides language support for GraphQL files.

SCSS IntelliSense autocompletes mixins, functions, etc. in sass files.

YAML provides YAML language support.

SQL Beautify provides formatting support for SQL files. I don’t always love the output, but it’s better than nothing.

Vue – Official (previously Volar) provides language, autocompletion, and other features for vue framework.

Markdown All in One provides keyboard shortcuts, formatting helpers, preview, and more for markdown files.

Tailwind CSS IntelliSense provides suggestions, highlights duplicates, and more for Tailwind class names.

Git

GitLens — Git supercharged provides some great features; my favorite is the code history on the active or hovered line.

GitLab Workflow is a wonderful integration with GitLab; I use the “copy active link to clipboard” feature daily to copy a permalink for specific line(s) when discussing code with my colleagues. It also provides helpful CI features including autocompletion and hints when editing .gitlab-ci.yml files, as well as showing pipeline/job status right in the VS Code status bar.

Adding Sentry to a Laravel/Inertia/Vue 3 app

I’m in the process of adding Sentry to a Laravel app that uses Laravel Jetstream with Inertia.js and Vue 3, and the Sentry Vue 3 documentation wasn’t working for me because the app setup was wrapped inside a createInertiaApp function.

The key is to add Sentry in the setup method of that function:

MySQL Table Size

Ever wondered which database or tables are taking up disk space on a MySQL/MariaDB server?

This query will provide the size of each table:

SELECT TABLE_SCHEMA AS `Database`,
TABLE_NAME AS `Table`,
ROUND(((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024), 2) AS `Size (MB)`,
ROUND((data_free / 1024 / 1024), 2) AS `Reclaimable Size (MB)`
FROM information_schema.TABLES
-- WHERE `TABLE_SCHEMA` = 'database_name'
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;