-
Laravel + Filament + Private Disk on Cloudways
Introduction
After a long, frustrating debugging exercise, I have discovered a quick tip for my Cloudways + Laravel friends. The “why” is still a touch fuzzy for me at the moment, but I’ll at least explain the symptoms and the fix.
The goal: Add a private file upload to a Filament resource form, ensuring anonymous users cannot download/view the file.
The issue: Everything was working great in my ddev environment. I could preview and open the files if I was logged in, and I would see a 403 if I was logged out. When I moved this to a Cloudways server I was getting automatically logged-out every time I hit a private file URL. Weird, right?
Code
Here’s the final code (the Cloudways “fix” is further down this page):
-
Using OBS and Blackhole as an Audio Router
I’ve been having a difficult timing capturing aggregate audio from my Shure MV7 microphone and my system audio. After trying the BlackHole + Audio Midi Setup unsuccessfully I found some information about how to use OBS Studio + BlackHole to configure, monitor, and send audio from many sources into a single output.
Using this configuration I’m able to record, transcribe, etc. all of this audio as if it were a single input source. An added bonus is that I can monitor the input, adjust levels, etc. from the OBS Studio user interface.
For what it’s worth, this entire process seems to work exactly the same with the VB-CABLE Virtual Audio Device. Just use VB-CABLE instead of BlackHole 16ch in steps 3 and 5 below.
Step 1: Install Blackhole ( brew install blackhole-16ch )
Step 2: Install OBS Studio
Step 3: Open OBS Studio’s Settings. Set Audio ➙ Advanced ➙ Monitoring Device to BlackHole 16ch
Step 4: Add macOS Audio Capture sources to OBS Studio and configure each to use Monitor and Output (monitor is the important piece). In the example below I’m getting audio from my mic and two specific applications (Chrome and Zoom).
Step 5: Use BlackHole 16ch as the “microphone” or “input” in the app of your choosing (audio recorder, transcription tool, etc.)
Bonus Tip
What if you want to control your microphone output to Zoom (or whatever) from within OBS Studio? You can use a second virtual audio device! Using the OBS Audio Monitor plugin you can route the actual audio “Output” from OBS Studio to whatever device you choose. The “Audio Mixer” dock controls the output to BlackHole (via the “Monitor Only” setting and corresponding Monitor -> BlackHole 16ch application setting), and the Audio Monitor dock controls output to VB-CABLE (via the “Monitor and Output” setting).
- Install the VB-CABLE virtual audio device
- Install the OBS Audio Monitor plugin
- Enable the Audio Monitor dock
- Configure the output to go to VB-CABLE
- In whichever apps you’d like, use VB-CABLE as the “Microphone” (input) instead of your actual microphone.
- Make sure only your microphone is set to “Monitor and Output”
- If desired, you can set a hotkey to mute/unmute your microphone via Settings ➙ Hotkeys ➙ Shure MV7 in (for example)
- You can set the same hotkey to both mute and unmute, and it’s a universal hotkey that is available even if OBS isn’t the front window.
Bonus Bonus Tip
One more for ya! Enabling the Waveform plugin in the preview area is a great way to have a visual reminder when your microphone is “hot.”
After disabling all of the unneeded docks here’s what I’m left with:
You can even add additional waveforms for your other input sources:
-
You Can’t Fit a Square Title into a Round Url
For whatever reason I always mix up the format for markdown links.
Here are the possibilities (only one is valid):
- [https://agileadam.com](View my site)
- [View my site](https://agileadam.com) ← the correct one!
- (https://agileadam.com)[View my site]
- (View my site)[https://agileadam.com]
I’ve come up with a mnemonic I’m going to try employing:
You can’t fit a square title into a round url.
It’s a variation of the idiom, “You can’t fit a square peg into a round hole.”
Square brackets. Round parentheses. You get it.
Update July 12, 2024
This still isn’t landing for me. “Can’t fit a square peg into a round hole” is easy to remember… but which is the peg and which is the hole? Square URL? Square Title? Round Title? Hmmm.
Maybe an acronym would serve me better? Here are my initial thoughts:
- “SLAP” (Square Label, Address in Parentheses)
- “SNAP” (Square Name, Address in Parentheses)
- “SLAP” (Square Label, Address in Parentheses)
If you take a closer look, it’d be easy to accidentally assume “L” means “link” (aka URL), so maybe I should rule those out. That leaves me with “SNAP”, which seems like it’d be worth trying for awhile.
I wish I knew what drives me to go down these rabbit holes.
-
Writing Tests for Drush Commands
There are plenty of examples of these in the wild, but I figured I’d show a stripped down version of an automated Kernel test that successfully tests a drush command. The trick here is making sure you set up a logger and that you stub a few methods (if you happen to use $this->logger() and dt() in your Drush commands). Also featured in this example is the use of Faker to generate realistic test data.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150<?phpdeclare(strict_types = 1);namespace Drupal\mymodule\Tests\Kernel {use Drupal\KernelTests\KernelTestBase;use Drupal\mymodule\Drush\Commands\QueueCommands;use Drush\Log\DrushLoggerManager;use Faker\Factory;use Faker\Generator;use Psr\Log\LoggerInterface;/*** Tests the QueueCommands Drush command file.*/final class QueueTest extends KernelTestBase {/*** {@inheritdoc}*/protected static $modules = [// Enable all the modules that this test relies on.'auto_entitylabel','block','datetime','eck','field','file','image','link','mymodule','mysql','options','path','path_alias','system','text','user','views','views_bulk_operations',];/*** The Faker generator.** @var \Faker\Generator*/protected Generator $faker;/*** The logger.** @var \Drush\Log\DrushLoggerManager|\Psr\Log\LoggerInterface*/protected DrushLoggerManager|LoggerInterface $logger;/*** The Drush queue commands.** @var \Drupal\mymodule\Drush\Commands\QueueCommands*/protected QueueCommands $drushQueueCommands;/*** Set up the test environment.*/protected function setUp(): void {parent::setUp();// Install required config.$this->installConfig(['mymodule']);$this->faker = Factory::create();$logger_class = class_exists(DrushLoggerManager::class) ? DrushLoggerManager::class : LoggerInterface::class;$this->logger = $this->prophesize($logger_class)->reveal();$drushQueueCommands = new QueueCommands();$drushQueueCommands->setLogger($this->logger);}/*** Tests the mymodule:close-all-registration-entities drush command.*/public function testCloseAllRegistrationEntities() {// Create a single registration entity.// Just demoing simple Faker usage for blog post.$registration = \Drupal::entityTypeManager()->getStorage('registration')->create(['type' => 'appt','field_echeckin_link' => $this->faker->url(),'field_epic_id' => $this->faker->randomNumber(9),'field_first_name' => $this->faker->firstName(),'field_has_echeckin_link' => $this->faker->boolean(),'field_outcome' => NULL,'field_phone_number' => $this->faker->phoneNumber(),'field_sequence_is_open' => 1,]);$registration->save();// This method exists in my module's Drush command file.// It sets field_sequence_is_open field to 0 for all open reg entities.// drush mymodule:close-all-registration-entities.$this->drushQueueCommands->closeAllRegistrationEntities();// Assert that the registration entity was updated.$this->assertCount(1, \Drupal::entityTypeManager()->getStorage('registration')->loadByProperties(['field_sequence_is_open' => 0]));}}}namespace {if (!function_exists('dt')) {/*** Stub for dt().** @param string $message* The text.* @param array $replace* The replacement values.** The text.*/function dt($message, array $replace = []): string {return strtr($message, $replace);}}if (!function_exists('drush_op')) {/*** Stub for drush_op.** @param callable $callable* The function to call.*/function drush_op(callable $callable) {$args = func_get_args();array_shift($args);return call_user_func_array($callable, $args);}}}I learned this via the migrate_tools project here.
-
Using Data Providers in PHPUnit Tests
This is the before code, where a single test is run, and each scenario I’m testing could be influenced by the previous scenario (not a good thing, unless that was my goal, which it was not).
123456789101112131415161718192021/*** Tests the getRegistrationsWithinNumDays function.*/public function testGetRegistrationsWithinNumDays() {$firstRegDateTimeStr = '2024-03-18 08:00:00';$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-18 09:00:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-19 13:00:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-20 14:20:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-20 17:35:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-21 17:35:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-22 08:00:00']);$registrations = Utils::getIdsOfRegistrationsWithinNumDays(1, $firstRegDateTimeStr);$this->assertEquals(2, count($registrations));$registrations = Utils::getIdsOfRegistrationsWithinNumDays(2, $firstRegDateTimeStr);$this->assertEquals(3, count($registrations));$registrations = Utils::getIdsOfRegistrationsWithinNumDays(3, $firstRegDateTimeStr);$this->assertEquals(5, count($registrations));}This is the after code, where three tests are run. Unfortunately this takes 3x longer to execute. The upside is that each scenario cannot affect the others and it’s perhaps more readable and easier to add additional scenarios.
123456789101112131415161718192021222324252627/*** Data provider for testGetRegistrationsWithinNumDays.*/public function registrationsDataProvider() {return [// Label => [numDays, expectedCount].'1 day' => [1, 2],'2 days' => [2, 3],'3 days' => [3, 5],];}/*** Tests the getRegistrationsWithinNumDays function using a data provider.** @dataProvider registrationsDataProvider*/public function testGetRegistrationsWithinNumDays($numDays, $expectedCount) {$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-18 08:00:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-19 09:00:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-20 13:00:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-21 14:20:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-21 17:35:00']);$this->mhpreregTestHelpers->createRegistration(['field_reg_datetime' => '2024-03-22 17:35:00']);$registrations = Utils::getIdsOfRegistrationsWithinNumDays($numDays, '2024-03-18 08:00:00');$this->assertEquals($expectedCount, count($registrations), "Failed for {$numDays} days.");} -
Showing Salesforce RecordId in the Contact List
I have a table of contacts in Salesforce. To my surprise you cannot choose to show the RecordId (e.g., 0036g00002d4BOHAAU) in the field configuration. I did some digging and it seems it’s not possible without using some workaround that requires a calculated field or similar. I just needed the IDs for a one-time use. I realized the ID is included in the linked fields but didn’t want to have to hover each of the “Name” and “Account Name” links to see the IDs. So, I whipped up some javascript to simply append the RecordId to the end of these links. I can run the javascript via console, or even add it to a user script or place it automatically with a code injector extension.
-
Skip Empty Values During Concatenation in a Drupal 10 Migration
UPDATE. You can do the same as below without the need for a custom module function. (source)
123456789field_address_full:- plugin: callbackcallable: array_filtersource:- address_1- city- state- plugin: concatdelimiter: ', '
This quick example shows one method to ignore empty values in a Drupal 10 migration concatenate process.
Code
Migrate code:
12345678910111213141516process:temp_address_parts:-plugin: skip_on_emptymethod: processsource:- address_1- city- state-plugin: callbackcallable: mymodule_migrate_filter_emptyfield_address_full:plugin: concatdelimiter: ', 'source: '@temp_address_parts'Module code:
12345678/*** Filters out empty values from an array.*/function mymodule_migrate_filter_empty($values): array {return array_filter($values, function ($value) {return !empty($value);});}Result
Example data:
1234address_1,city,state1 Main St.,Portland,ME2 Main St.,Portland,Portland,MEResulting field_address_full plain text value
1231 Main St., Portland, ME1 Main St., PortlandPortland, ME -
Using lnav to View Live DDEV Logs
https://lnav.org/ is a fantastic tool to work with log files. The SQL query functionality is a lot like https://github.com/multiprocessio/dsq, which also impresses.
My quick tip is that lnav can read from stdin. This means you can pipe content into it. Here’s how I monitor my ddev environment in realtime. Hooray for colors!
1ddev logs -f | lnav -qIf you haven’t explored lnav you’ll want to dive into the documentation a bit. It does a lot more than just give pretty colors to your log files / output. https://docs.lnav.org/en/v0.11.2/hotkeys.html is certainly worth bookmarking.
-
Rendering null boolean values with Grid4PHP
I’m a big fan of Grid4PHP for adding a quick CRUD user interface to a MySQL database.
I have a lot of boolean columns, like “is_telehealth” that need to start as NULL until we make a determination about the field’s value. Grid4PHP renders null booleans as “0” in the table/edit form. This is misleading, given it’s not actually a 0 value. If you add the ‘isnull’ attribute to the column it lets you save the null value, but it still renders as a “0”.
Using a custom formatter I was able to render an empty string (nothing) instead of “0” for these null booleans:
1234$boolNullFormatter = "function(cellval,options,rowdata){ return cellval === null ? '' : cellval; }";$cols[] = ['name' => 'is_in_gmb', 'isnull' => true, 'formatter' => $boolNullFormatter];$cols[] = ['name' => 'is_property', 'isnull' => true, 'formatter' => $boolNullFormatter];$cols[] = ['name' => 'is_telehealth', 'isnull' => true, 'formatter' => $boolNullFormatter]; -
Drupal Migrate API skip_on_multiple Process Plugin
Here’s a Migrate API process plugin I wrote in Drupal 9 that skips a property or row when more than one value exists. My use case:
- My source data has locations; each location has multiple associated organizations
- My new Drupal site has locations; each location has a parent organization
- I want to only populate the field_parent_org field (an entity reference) if there is a single organization value in the source data for the location
- I’m using a custom module called “pdms_migrate”
I’ve stripped out all of the noise from the examples below… hopefully it’s enough to help you understand the example: