Here’s the actual Oh Dear powered status badge for flareapp.io
Hopefully, Flare is online at the time you are reading this, and the above badge is green. Should Flare be down, the badge will be colored red.
In this post, I’d like to show you how we built these badges.
For each site and status page in your Oh Dear account, we offer badges that you can use to show the status of your site or status page everywhere you want.
The default badge shows the name of a site and its uptime status. Here's the actual badge for freek.dev
The small badge is a smaller version of the default badge that only shows the status of a site.
Here's the actual small badge for freek.dev:
At Oh Dear, you can find the badges in the "Badges" tab for each of the sites and status pages in your account. On that screen, you can easily copy the HTML code to use the badge you want.
Technically, the status badges are SVGs. You might think we store the SVGs on a disk somewhere, but that's not the case. We generate the SVGs on the fly using PHP.
Here again is the default badge for Flare:
Our codebase has a Blade component that can render the normal badge shown above. It accepts three properties: the label text ("flareapp.io"), the result text ("online"), and the color of the badge.
<x-normal-badge
:labelText="$labelText"
:resultText="$resultText"
:color="$color"
/>
The Blade view for this component looks like this. I've omitted some parts for brevity.
<svg id="svg" width="{{ $sizes['fullBadgeWidth'] }}" height="24" viewbox="0 0 {{ $sizes['fullBadgeWidth'] }} 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<!-- Some parts removed for brevity -->
<text id="labelText" transform="scale(.1)" x="330" y="160" fill="#fff">{{ $labelText }}</text>
<text id="status" x="{{ $sizes['textLabelX'] }}"
y="160" transform="scale(.1)" fill="{{ $colors['text'] }}">{{ $resultText }}
</text>
</svg>
You can see that the Blade view contains code to render an SVG. To make the SVG dynamic, we use some variables passed to the Blade component, such as $labelText
and $resultText
.
You can imagine that the SVG needs to grow or shrink depending on the length of the label text and result text. The total length the SVG needs to be, is stored in the $sizes['fullBadgeWidth']
variable. This number is calculated in the component class that renders the badge.
The font used in the badge isn't a monospace font, meaning each character has a different width. Luckily, PHP has a nifty function called imagettfbbox
that can calculate the width of a string in a given font of a specific font size.
$fontPath = base_path('resources/fonts/arial/Arial.ttf');
$labelBoundingBox = imagettfbbox(
size: 8,
angle: 0,
font_filename: $fontPath,
text: $labelText,
);
$labelWidth = $labelBoundingBox[2];
We do the same for the result text. We then calculate the width of the SVG by adding the width of the label text, the width of the result text, and some padding.
And voila, we have a dynamic SVG that can grow and shrink depending on the length of the label and result text.
In the section above, we saw how to generate an SVG for a badge. Let's now look at the controller that handles the request for a badge.
namespace App\Http\Front\Controllers\Badges;
use App\Domain\Site\Actions\GenerateSiteUptimeStatusBadgeSvgAction;
use App\Domain\Site\Models\Site;
class SiteUptimeStatusBadgeController
{
public function __invoke(Site $site, string $type)
{
$svg = app(GenerateSiteUptimeStatusBadgeSvgAction::class)->execute($site, $type);
return response($svg, headers: [
'Content-Type' => 'image/svg+xml',
'Cache-Control' => 'max-age=30, s-maxage=30, stale-while-revalidate=30',
]);
}
}
The GenerateSiteUptimeStatusBadgeSvgAction
is a class that we use to generate the SVG itself.
You can see that the controller returns the SVG as a response. The response has a Content-Type
header that tells the browser that the response is an SVG. The response also has a Cache-Control
header that has three parts to it:
max-age=30
: this tells the browser that it can cache the response for 30 seconds.s-maxage=30
: this tells shared caches, such as CDNs and proxy servers that they can cache the response for 30 seconds.stale-while-revalidate=30
: this tells the shared caches that it can serve the cached response for 30 seconds after the cache has expired. The CDN will then revalidate the cache in the background.By setting these headers, we can ensure that the browser and shared caches don't have to request the badge from our servers whenever the badge is shown on a page. This saves us a lot of bandwidth and CPU cycles.
Now, in addition to those headers, we also use the spatie/laravel-responsecache package to cache the response on our server.
Let's look at the routes defined to handle the badge requests.
Route::prefix('badges')->middleware('cacheResponse:30')->group(function () {
Route::get('site/{siteUlid}/uptime/{type}', SiteUptimeStatusBadgeController::class);
Route::get('status-page/{statusPageUlid}/{type}', StatusPageStatusBadgeController::class);
});
The cacheResponse
middleware is provided by the spatie/laravel-responsecache
package. It will cache the response, in our case the rendered SVG for 30 seconds.
Together with the Cache-Control
headers, the middleware will ensure that the response will be cached on our server, in the browser, and on shared caches for a short time. So our badge is always fresh enough and we don't have to generate it on every request.
I hope this post gave you some insights into how we generate the status badges at Oh Dear. To try the badges yourself, sign up for a free Oh Dear trial.
Oh Dear can monitor your entire site, not just the homepage. It will actually crawl your entire website and report any broken links. You can monitor your scheduled jobs, if Horizon is up, your SSL certificate is valid, your DNS records, and more. I've put a lot of love into Oh Dear, and I hope you'll like it.
]]>The malware targets apps with APP_DEBUG set to true. When enabled, Laravel will give detailed error messages, and some security features will be disabled. In production, you always want this value to be set to false.
You can make sure it's always set to' false' using Oh Dear’s application monitoring feature. We can notify you whenever someone should set it to true. Let’s go through the steps required to set this up.
Read more]]>ou can get all holidays for a country by using the get
method.
use Spatie\Holidays\Holiday;
// returns an array of Belgian holidays
// for the current year
$holidays = Holidays::for('be')->get();
Alternatively, you could also pass an instance of Country
to the for
method.
use Spatie\Holidays\Holiday;
use Spatie\Holidays\Countries\Belgium;
// returns an array of Belgian holidays
// for the current year
$holidays = Holidays::for(Belgium::make())->get();
You can also pass a specific year.
use Spatie\Holidays\Holiday;
$holidays = Holidays::for(country: 'be', year: 2024))->get();
If you need to see if a date is a holiday, you can use the isHoliday
method.
use Spatie\Holidays\Holiday;
Holidays::for('be')->isHoliday('2024-01-01'); // true
If you need the name of the holiday, you can use the getName
method.
use Spatie\Holidays\Holiday;
Holidays::for('be')->getName('2024-01-01'); // Nieuwjaar
We've made this package for our own needs and packaged it, so you don't have to code it up in your project. You can see a list of all packages we've made previously on our company website.
]]>Some people have asked if they can use Tailwind to style their documents. And I’m happy to report you can. Let’s take a look at a minimal example to showcase this.
In your project, you need to save a Blade view with content like this. In this view, I used the CDN version of Tailwind (in your project you can use an asset built with vite) and got an invoice layout from one of the many Tailwind template sites.
<html lang="en">
<head>
<title>Invoice</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="px-2 py-8 max-w-xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div class="flex items-center">
<div class="text-gray-700 font-semibold text-lg">Your Company Name</div>
</div>
<div class="text-gray-700">
<div class="font-bold text-xl mb-2 uppercase">Invoice</div>
<div class="text-sm">Date: 01/05/2023</div>
<div class="text-sm">Invoice #: {{ $invoiceNumber }}</div>
</div>
</div>
<div class="border-b-2 border-gray-300 pb-8 mb-8">
<h2 class="text-2xl font-bold mb-4">Bill To:</h2>
<div class="text-gray-700 mb-2">{{ $customerName }}</div>
<div class="text-gray-700 mb-2">123 Main St.</div>
<div class="text-gray-700 mb-2">Anytown, USA 12345</div>
<div class="text-gray-700">johndoe@example.com</div>
</div>
<table class="w-full text-left mb-8">
<thead>
<tr>
<th class="text-gray-700 font-bold uppercase py-2">Description</th>
<th class="text-gray-700 font-bold uppercase py-2">Quantity</th>
<th class="text-gray-700 font-bold uppercase py-2">Price</th>
<th class="text-gray-700 font-bold uppercase py-2">Total</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-4 text-gray-700">Product 1</td>
<td class="py-4 text-gray-700">1</td>
<td class="py-4 text-gray-700">$100.00</td>
<td class="py-4 text-gray-700">$100.00</td>
</tr>
<tr>
<td class="py-4 text-gray-700">Product 2</td>
<td class="py-4 text-gray-700">2</td>
<td class="py-4 text-gray-700">$50.00</td>
<td class="py-4 text-gray-700">$100.00</td>
</tr>
<tr>
<td class="py-4 text-gray-700">Product 3</td>
<td class="py-4 text-gray-700">3</td>
<td class="py-4 text-gray-700">$75.00</td>
<td class="py-4 text-gray-700">$225.00</td>
</tr>
</tbody>
</table>
<div class="flex justify-end mb-8">
<div class="text-gray-700 mr-2">Subtotal:</div>
<div class="text-gray-700">$425.00</div>
</div>
<div class="text-right mb-8">
<div class="text-gray-700 mr-2">Tax:</div>
<div class="text-gray-700">$25.50</div>
</div>
<div class="flex justify-end mb-8">
<div class="text-gray-700 mr-2">Total:</div>
<div class="text-gray-700 font-bold text-xl">$450.50</div>
</div>
<div class="border-t-2 border-gray-300 pt-8 mb-8">
<div class="text-gray-700 mb-2">Payment is due within 30 days. Late payments are subject to fees.</div>
<div class="text-gray-700 mb-2">Please make checks payable to Your Company Name and mail to:</div>
<div class="text-gray-700">123 Main St., Anytown, USA 12345</div>
</div>
</div>
</body>
</html>
In a Laravel application where Laravel PDF is installed, you can add a controller like this. The above view is saved in resources/views/pdf/invoce
.
namespace App\Http\Controllers;
use function Spatie\LaravelPdf\Support\pdf;
class DownloadInvoiceController
{
public function __invoke()
{
return pdf('pdf.invoice', [
'invoiceNumber' => '1234',
'customerName' => 'Grumpy Cat',
]);
}
}
When you hit that controller, a PDF like this one will be downloaded.
Neat, right?
This shows how easy it is to create a beautiful invoice using Tailwind and our PDF package. Of course, in a real-world scenario, you would make more things, like the line items and prices, ... dynamic.
To learn more about Laravel PDF, head over to the package's extensive documentation.
]]>When hiring someone at Spatie, I usually tend to get a feel if a candidate is passionate about our field of work.
I ask questions like:
In most cases, these questions spark a nice conversation that covers the technical bits too.
]]>You probably notice that this post is a lot shorter than usual. I'm experimenting with a new short format to share some quick thoughts.
In this post, I’d like to introduce and demonstrate the package.
Once the package has been installed, you can use the PDF
facade to create PDFs. The basis for PDF creation is HTML, and the easiest way to generate HTML in a Laravel app is by using a view.
Here's an example of creating a PDF from a Blade view.
use Spatie\LaravelPdf\Facades\Pdf;
Pdf::view('pdf.invoice')->save('/some/directory/invoice.pdf');
Under the hood, the package will, via Browsershot, spin up an instance of Chrome, load your HTML, and let Chrome save a PDF. Because Chrome has a state-of-the-art rendering engine, you should be able to use modern CSS features to create your PDF.
As a second parameter, you can pass an array of data that will be made available in the view. You might use that to pass an Eloquent model, such as an invoice, to the view.
use Spatie\LaravelPdf\Facades\Pdf;
Pdf::view('pdf.invoice', ['invoice' => $invoice])
->save('/some/directory/invoice.pdf');
In addition to saving locally, you could save your PDF on any of your configured disks.
Here's an example of saving a PDF to the s3
disk.
use Spatie\LaravelPdf\Facades\Pdf;
Pdf::view('invoice')
->disk('s3')
->save('invoice-april-2022.pdf');
Instead of using a Blade view, you can also create a PDF from a string of HTML.
use Spatie\LaravelPdf\Facades\Pdf;
Pdf::html('<h1>Hello world!!</h1>')->save('/some/directory/invoice.pdf');
In a controller, you can create and return a PDF using the pdf()
function.
use function Spatie\LaravelPdf\Support\pdf;
class DownloadInvoiceController
{
public function __invoke(Invoice $invoice)
{
return pdf()
->view('pdf.invoice', compact('invoice'))
->name('invoice-2023-04-10.pdf');
}
}
By default, the PDF will be inlined in the browser. This means that the PDF will be displayed in the browser if the browser supports it. If the user tries to download the PDF, it will be named "invoice-2023-04-10.pdf". We recommend that you always name your PDFs.
You can use the download()
method to force the PDF to be downloaded.
use function Spatie\LaravelPdf\Support\pdf;
class DownloadInvoiceController
{
public function __invoke(Invoice $invoice)
{
return pdf()
->view('pdf.invoice', compact('invoice'))
->name('invoice-2023-04-10.pdf')
->download();
}
}
The JavaScript in your HTML will be executed by Chrome when the PDF is created. You could use this to have a JavaScript charting library render a chart.
Here's a simple example. If you have this Blade view...
<div id="target"></div>
<script>
document.getElementById('target').innerHTML = 'hello';
</script>
... and render it with this code...
use Spatie\LaravelPdf\Facades\Pdf;
Pdf::view('your-view')->save($pathToPdf);
... the text hello
will be visible in the PDF.
Generating a PDF can be slow, as an entire instance of Chrome needs to be started. In your tests, this slowness can be bothersome. That’s why the package ships with a PDF fake. No PDF generation will occur when using this fake, and your tests will run faster.
// in your test
use Spatie\LaravelPdf\Facades\Pdf;
beforeEach(function () {
Pdf::fake();
});
When PDF generation is faked, you can use some powerful assertion methods.
You can use the assertSaved
method to assert that a PDF was saved with specific properties. You should pass it a callable which will receive an instance of Spatie\LaravelPdf\PdfBuilder
. If the callable returns true
, the assertion will pass.
use Spatie\LaravelPdf\Facades\Pdf;
use Spatie\LaravelPdf\PdfBuilder;
Pdf::assertSaved(function (PdfBuilder $pdf) {
return $pdf->downloadName === 'invoice.pdf'
&& str_contains($pdf->html, 'Your total for April is $10.00'));
});
The assertRespondedWithPdf
method can be used to assert that a PDF was generated and returned as a response.
Imagine you have this route:
use Spatie\LaravelPdf\Facades\Pdf;
Route::get('download-invoice', function () {
return pdf('pdf.invoice')->download('invoice-for-april-2022.pdf');
});
In your test for this route you can use the assertRespondedWithPdf
to ensure that a PDF was generated and returned as a download. You can even make assertions on the content of the PDF.
use Spatie\LaravelPdf\Facades\Pdf;
use Spatie\LaravelPdf\PdfBuilder;
it('can download an invoice', function () {
$this
->get('download-invoice')
->assertOk();
Pdf::assertRespondedWithPdf(function (PdfBuilder $pdf) {
return $pdf->downloadName === 'invoice-for-april-2022.pdf'
&& $pdf->isDownload()
&& str_contains($pdf->html, 'Your total for April is $10.00'));
});
});
Generating PDFs locally can be resource-intensive. If you're having to generate a lot of PDFs or having trouble installing the necessary dependencies on your server, you may want to consider using AWS Lambda to generate your PDFs.
To generate PDFs on AWS Lambda, you must install these two packages in your app.
With these two packages installed, you can generate PDFs on AWS Lambda like this:
Pdf::view('pdf.invoice', $data)
->generateOnLambda()
->save('invoice.pdf');
If you want to create all PDFs in your app on Lambda, you can set it as a default like this:
// typically, in a service provider
Pdf::default()->generateOnLambda();
I had fun coding this package, and I hope you will enjoy using it. There are many options that I didn’t cover in this post, such as formatting PDFS, adding headers and footers, using page breaks, and much more!
Laravel PDF uses headless Chrome to generate PDFs. This is a great solution, as you can use any CSS you want, and it will be rendered correctly. However, generating a PDF this way can be resource-intensive. If you don’t like this trade-off, a great alternative to check out is laravel-dompdf.
Laravel PDF is just one of many packages that we've made. You'll find an extensive list of Laravel and PHP packages we released previously on our company website. There's probably something there for your next project. If you like our open-source stuff, be sure to also look at our paid products that make working on open-source sustainable for our company.
]]>At the same time, we’ve also released a new major version of spatie/laravel-medialibrary. This is a powerhouse package that can associate all kinds of files with Eloquent models. The latest version of this package now uses spatie/image v3 under the hood for manipulating images.
In this blog post, I’ll explain why we made new major versions and how they can be used.
In the remainder of this post, we will manipulate this beautiful photograph taken in New York City.
Let's start with a simple manipulation. The package allows you to easily load up an image, perform some manipulations, and save it again, overwriting the original image.
Image::load('new-york.jpg')
->sepia()
->blur(50)
->save();
Imagine you want to convert a jpg
to a png
. Here's how to do that. You should have to specify a path with the correct extension.
Image::load('new-york.jpg')->save('converted.png');
One of the more powerful methods is fit
. This operation will try to fit an image within given boundaries. Here’s an example where we will resize an image and fit it within the width and height boundaries without cropping, distorting, or altering the aspect ratio.
$image->fit(Fit::Contain, 300, 300);
Here’s what the resulting image looks like.
So its aspect ratio wasn’t altered (it does not look stretched), and the resulting image fits in a 300x300 box.
There are a couple more fit modes such as Fix::Max
, Fit::Fill
, Fit::strech
. These fit
operations are quite powerful because you don’t need to calculate the resulting dimensions, which can be tricky. The package takes care of all this.
Of course, the package supports many more image manipulations. Check out the the extensive documentation to learn more.
If you already use spatie/image
, you might wonder what’s new in v3. And the truth is that in the public API, almost nothing changed, but under the hood, the new v3 is entirely other beast than v2.
v2 of our package uses Glide (by Jonathan Reinink) to perform image manipulations. Glide can generate images on the fly using URL parameters. With Glide installed in your project, a cropped version of the image in this bit of html will be automatically generated.
<img src="image.jpg?w=300&h=400&fit=crop" />
Its API is really nice to work with. Under the hood, Glide leverages the popular Intervention Image package by Oliver Vogel, but it hides some of the complex operations behind easy-to-use parameters. The various fit
operations described above were calculated in spatie/image v2’s Glide dependency.
So while spatie/image v2 is a joy to work with as a user, there’s quite some overhead under the hood. We have to translate all methods calls to a query string like ?w=300&h=400&fit=crop
and feed that to Glide’s server, which translates it to Intervention Image calls under the hood.
In v3, this is vastly simplified. In fact, spatie/image v3 has no dependency at all (besides the GD and Imagick extension). Technically, v3 is a fork of Intervention Image, where we modernized the codebase and refactored it for readability. We added a modern test suite (more on that later). On top of that code base, we added all of the image manipulation operations available in Glide.
This makes spatie/image v3 much more efficient and maintainable than spatie/image v2.
While developing v3, we took some time to develop a good way to test image manipulations. The easiest way to verify an operation is correct is to take a hash of the resulting file.
it('can adjust the brightness', function () {
$targetFile = $this
->tempDir
->path("brightness-test-result.png");
Image::load(getTestJpg())->brightness(-50)->save($targetFile);
$resultHash = md5($targetFile);
expect($resultHash)->toBe(/* some hardcoded value */);
});
If you would only want to run this test suite on one computer, it would be fine. But on other computers, and over time, you could use different versions of GD and Imagick. These versions might produce slightly different results. If you work with a hash to verify if the resulting image is what you expect, than even a difference of one pixel in your image would result in a different hash and a failing test.
This can be solved by using a JavaScript tool like Pixelmatch instead of relying on a hash. Pixelmatch can compare two images and return the percentage of matching pixels. We made a package called spatie/pixelmatch-php to easily use this tool using PHP. Here’s how it can be used.
use Spatie\Pixelmatch\Pixelmatch;
$pixelmatch = Pixelmatch::new("path/to/file1.png", "path/to/file2.png");
$pixelmatch->matchedPixelPercentage(); // returns a float, for example 97.5
$pixelmatch->mismatchedPixelPercentage(); // returns a float, for example 2.5
There’s also a matches
function to check if the images match.
use Spatie\Pixelmatch\Pixelmatch;
$pixelmatch = Pixelmatch::new("path/to/file1.png", "path/to/file2.png");
$pixelmatch->matches(); // returns a boolean
$pixelmatch->doesNotMatch(); // returns a boolean
A key feature of Pixelmatch is that you can set a threshold. Higher values will make the comparison more sensitive. The threshold should be between 0 and 1.
use Spatie\Pixelmatch\Pixelmatch;
// while be true even if the image has slight variations
$isSameImage = Pixelmatch::new("path/to/file1.png", "path/to/file2.png")
->treshold(0.1)
->matches();
With this in mind, we could rewrite our test like this.
it('can adjust the brightness', function () {
$targetFile = $this->tempDir->path("brightness-test-result.png");
Image::load(getTestJpg())
->brightness(-50)
->save($targetFile);
$imageWithBrighnessAdjusted = $this
->fixturesPath('brightness.png')
$isSameImage = Pixelmatch::new(
imageWithBrighnessAdjusted,
$targetFile,
)
->threshold(0.1)
->matches();
expect($isSameImage)->toBeTrue();
});
Nice! Now, our test will only succeed if there are slight variations in the output caused by using different versions of GD/Imagick.
We could call it a day here, but we went one step further. Our test suite has hundreds of tests, so we want them to be as small as possible while retaining readability.
To create the test above, we first manually created a file with the brightness adjusted ($imageWithBrighnessAdjusted
) so we could use that as a file to compare against.
A far better way to handle this is to leverage snapshot testing. When using snapshot testing, the first time you run the test, the result is automatically saved; the next time you run a test, its output will be compared to the saved result. Snapshot testing is a large subject in itself. To learn more about it, check out this video from our testing course.
To work with images, we added a new function to our PHPUnit snapshots assertions package: assertMatchesImageSnapshot
. We also added that function to the Pest specific package.
The first time you run a test containing assertMatchesImageSnapshot($pathToImage)
, the passed image will be written in the snapshots directory. You should then manually make sure that that image is correct. The next time you run the tests, the passed image will be compared to the image written in the snapshots directory. If it is the same, the test passes. If it is not, the test fails.
As a bonus, you can pass a Pixelmatch threshold as a second parameter to assertMatchesImageSnapshot
. By default, it is set to 0.1
assertMatchesImageSnapshot($targetFile, 0.2)
With that snapshot assertion, we can make our test much smaller.
it('can adjust the brightness', function () {
$targetPath = $this->tempDir->path("brightness-test-result.png");
Image::load(getTestJpg())
->brightness(-50)
->save($targetFile);
assertMatchesImageSnapshot($targetFile);
});
Beautiful, right? We could call it a day here, but as it stands, this test now only supports Imagick (because that’s selected under the hood by Image::load())
). Our package supports both GD and Imagick. Let’s leverage Pest’s dataset feature to make sure both GD and Imagick work correctly using a single test.
First, a dataset that contains both drivers needs to be defined. You can put this in your tests/datasets
directory or the Pest.php
file.
dataset('drivers', [
'imagick' => [Image::useImageDriver('imagick')],
'gd' => [Image::useImageDriver('gd')],
]);
With the dataset defined, we can now adjust our tests to run once per driver in the dataset.
it('can adjust the brightness', function (ImageDriver $driver) {
$targetFile = $this->tempDir->path("{$driver->driverName()}/brightness.png");
$driver->loadFile(getTestJpg())
->brightness(-50)
->save($targetFile);
assertMatchesImageSnapshot($targetFile);
})->with('drivers');
In my mind, this is an incredibly powerful test. It covers both GD and Imagick. It will also create a snapshot image per driver the first time you run that test. We can manually inspect that file if the correct results were rendered. On the next run of the test, it will verify if the newly created results match the snapshot.
In the test above, we tested the brightness operation, which is fairly simple. The combination of snapshot testing and datasets really shines when you want to test a more complex operation, like cropping, which takes multiple parameters.
it('can crop an image relative to a position', function (
ImageDriver $driver,
array $cropArguments,
int $expectedWidth,
int $expectedHeight,
) {
$targetFile = $this->tempDir
->path("{$driver->driverName()}/manual-crop.png");
$driver
->loadFile(getTestJpg())
->crop(...$cropArguments)
->save($targetFile);
assertMatchesImageSnapshot($targetFile);
})->with('drivers')->with([
[[50, 100, CropPosition::TopLeft], 50, 100],
[[50, 100, CropPosition::Center], 50, 100],
[[50, 100, CropPosition::Top], 50, 100],
[[50, 100, CropPosition::Left], 50, 100],
[[50, 100, CropPosition::Right], 50, 100],
[[50, 100, CropPosition::BottomRight], 50, 100],
[[50, 100, CropPosition::BottomLeft], 50, 100],
[[50, 100, CropPosition::TopLeft], 50, 100],
[[50, 100, CropPosition::TopRight], 50, 100],
[[1000, 100, CropPosition::Center], 340, 100],
[[50, 1000, CropPosition::Center], 50, 280],
[[50, 1000, CropPosition::Top], 50, 280],
]);
If you want to see more examples of this way of testing, checkout out the test suite of spatie/image.
All this testing goodness nearly made me forget we have released a new major version of Laravel Media Library as well. As you can suspect by its version number, v11, Media Library is already a very mature package. The latest version is more of a maintenance release where we polished some bits here and there.
The most significant change is that it uses spatie/image v3 under the hood. But because that API between image v2 and v3 was nearly identical, you won’t quickly run into breaking changes. Upgrading from v10 to v11 is straightforward.
When you define a media conversion, you can still use all image manipulations you know and love.
// on your model
public function registerMediaConversions(?Media $media = null): void
{
$this
->addMediaConversion('thumb')
->width(50)
->height(50)
->nonQueued();
}
When creating image v2 years ago, I still remember thinking I should remove that dependency on Glide someday. I’m happy that today is that day.
Our image package is now a robust solution for manipulating images using PHP. It uses modern PHP features, like enums, and has an easy-to-maintain test suite. By making our Media Library package use spatie/image v3 under the hood, Glide isn’t required anymore.
I’d like to stress that Oliver’s and Jonathan’s excellent work on Intervention Image and Glide made spatie/image v3 possible. My colleague Tim also did a lot of good work on spatie/image v3.
Of course, this isn’t the first time we created a package. You can view an extensive list of all open source work we’ve done on our company website. Also take a look at Media Library Pro which contains front-end components to upload and administer Laravel Medialibrary file collections.
]]>Here are all the details of our sale from 20/11 until 27/11: 🚀 Enjoy a 30% discount on all Spatie products 🥳 Get a nice 50% discount during our Flash Sales!
⚡️ Flash sale overview: 20/11: 50% off on Laravel Comments and Media Library Pro
21/11: 50% off on Testing laravel and Laravel Beyond CRUD
22/11: 50% off on all self-hosted Mailcoach licenses
23/11: 50% off on Writing Readable PHP and Laravel Package Trailing
24/11: grab Ray lifetime licenses, but also 50% discount on Ray renewals, this offer is valid during the entire weekend
27/11: 50% off on Front Line PHP and Event Sourcing Laravel
Flare: 50% off first 3 months for new customers, use code FLAREBLACKFRIDAY 👉 Visit Flare
Mailcoach: 50% off first 3 months for new customers, use code MAILCOACHBLACKFRIDAY 👉 Visit Mailcoach
]]>In this blog post, I'll share how this album was made from conception to release.
If you're here for programming stuff, don't worry; future posts will be on that topic again.
I've been recording music for more than 20 years now. Please don't think I'm a professional. My primary profession has always been programming, and creating music is more of a hobby.
My first records, titled Draw, Second and Left, were made during my student years. They feature lots of picking on my acoustic guitar with some electronica on top of that. These tracks were recorded with a cheap PC and Cubase.
When I started working, I still played in bands, but I stopped recording at home. Right before COVID happened, one of my bandmates started getting interested in analog synths and how to record them, and I got swept away in it too. When Covid kicked in, our weekly band practice couldn't happen anymore. To satisfy my music cravings, I bought my first analog synth: a Behringer Model D.
My bandmate taught me how to record stuff with Ableton, an application I love working with. I bought a guitar for recording at home, and a couple of more synths: a Moog Matriarch, a Prophet 6 and a Moog Sound Studio. For the next few months I had an absolute blast experimenting with these synths, and I also recorded two new albums: Wave and Current.
While recording, I bought a few more synths 😬. This is what my recording setup looks like now. My apologies for the cable management.
At the beginning of this year, I wanted to create a new album again, but I wanted to do it differently this time. My previous albums didn't have a natural common thread in them. Songs on them were made entirely separately, and when I had enough songs, I just grouped them on a release. For the new album, I wanted all songs to be a more cohesive whole, and they would make each other stronger.
In hindsight, it is evident that my then-latest album Current had the flaw described above. Sure, all songs were made on synths, but each has a different feel and inspiration. Let's go through all the tracks of Current:
So, all of these tracks have a very different feel. They're good songs on themselves, but they need more cohesion. They don't make each other stronger.
Last year, the excellent album Motomami by Rosalia was released. I liked it and started hunting for information on how that was made. This interview in which she explains her process was fascinating. For her album, she had a few ground rules (clear vocals, aggressive drums, making collages, etc.). By keeping those rules in mind while recording, all songs fit together. So yeah, it's like creating a mood board with the things you want.
I started by writing down what I wanted the new album to sound like. I used my favorite track from the previous album, Boiling Sea, as a starting point. I wanted all tracks of the new album to be:
By keeping these things in mind, I hoped to create a collection of songs that would fit well together.
Even before recording started, I already had the name "Kind" in mind. It just sounds well. All my albums have also a hidden feature. Here are all the titles again: Draw, Second, Left, Audience, Wave, Current, Kind. Can you see the common thread? All of these words have a double meaning. I very much like that so that the person listening to the album can pick the meaning he or she thinks best fits the music.
For all my albums, the cover always is a photo I took myself. Let's take a look at the cover for Kind.
I took this photo while on holiday on a beach in Norway. It really like how the clouds have a painting-like quality.
I started working on this album in January 2023. As a starting point, I bought this excellent collection of patches by Anton Anru for the Moog Matriarch. I'll explain this to people not familiar with modular synths. The Moog Matriarch can produce very lovely sounds out of the box. On the synth itself, there are many patch points that you can connect via cables. This will change the internal sound path, dramatically changing the sound. Here's my Matriarch with a few patch cables plugged in.
That patch collection by Anton Anru is a PDF with patches that sound very good. Here's what such a patch looks like.
All the patches by Anton are self-evolving, meaning that if you press one note or chord, or create a little sequence, you'll always get different sounds, and it'll always flow. I tried to set up a few patches from the PDF and modified that to a sound I liked. These sounds were the foundation of the tracks Kind and Pressure Dub. Especially on Pressure Dub, you can hear it very clearly. The song starts with an organ and pad sound that is always present and evolving.
Let's go over all the songs on Kind.
This was the first song that I created for this album. In fact, the album's tracklist is almost 100% in chronological recording order. The synth solo sound that starts around the 4:00 mark is me playing chords on the Prophet 6. While playing, I had a foot switch to control a high-pass filter. The output of the Prophet was also running through a Hologram Microcosm that chopped up the sounds in real-time.
In fact, most of the sounds of this track have run through the Microcosm to get weird rhythms and such. The heavy kick was created using a DFAM. The hat sounds are coming from the same DFAM together with an 808. After creating this track, I noticed that it reminded me of the fantastic album Engravings, by Forest Swords.
For this track, I didn't start with a synth sound. One day, I played my guitar and let the sound go through various delays, and my Mako M1 modulator.
When I stumbled upon a very particular nice sound, I immediately started recording a few chords for about 4 minutes. That recording is what you hear throughout the entire song; it's that wavy sound at the center. There were almost no edits here.
Next, I added the rhythm section. A steady kick and bass that sounds very much like the ones you hear in Boiling Sea. I consider Layoff and Boiling Sea to be two parts of one whole.
After recording Layoff, I wanted to do another track using the guitar. A friend sent me a link to [a lovely-looking pedal with a fantastic name, Mood. This pedal is a nice combination of a reverb and a micro sampler. I was intrigued and bought it.
After playing a whole time with this pedal using a guitar, I connected my Prophet 6 synth to it and started making a loop. One of the loops I created is used as a basis for There. You can hear it start the song.
The sounds you start hearing around the 1:15 mark are created using a guitar running through the Mood pedal too, looping and modifying it.
This track also features my very first guitar solo, which starts around 3:44. This solo gets its space using the Mood pedal as well. Here, you can hear its reverb capabilities in action. It's very warm and deep. I like that sound a lot.
The rhythm of this track is very, very strange. For the musicians amongst you, try to count the beats. I bet it's tough to do. Even I can't do it properly. Like I said above, the fundamental sound of this song is that loop I created with the mood. In Ableton, I matched the BPM to the length of that loop. But the sound inside the loop doesn't follow the exact BPM. That's what makes it strange, I guess.
I tried to add a simple four-on-the-floor kick to it, but I couldn't do that properly because of the strange rhythm. After experimenting, I noticed that fast-moving things were the only rhythms that worked. So I programmed some nice 808 toms and put them through a fast delay.
To me, this track has something Brian Eno-esque, a musician I admire a lot. It reminds me of some of his work for the Passengers album. If you're into music or anything creative, you should see this his fantastic interview on exploring creativity.
Chronologically, this was the second track I recorded, but I kept tinkering with this track while recording all other songs.
The basis for this track is two lines recorded with Moog Matriarch. You hear that organ and pad sounds at the start and throughout the entire song.
Around the 4:25 mark, a staccato organ sound will start. To me, it's a very "dubby" sound. While it plays, the organ will vary in volume; sometimes, you'll hear it with more highs. These variations are unintentional: I am playing and searching for the perfect sound. What you hear is my first take. Because I liked these variations very much, I just left them on the record instead of only using the perfect sound I found.
The "Where are we going" sample is taken from the track Providence by Godspeed You Black Emperor.
This song is the longest song I ever recorded. I was afraid it was too long and made some shorter versions, but a shorter cut made the song feel rushed. With the extended version on the album, the song is spacious and has much breathing room.
When finishing the entire album, I let a few close friends listen to the entire thing, and several buddies pointed this song out as their favorite. I guess I did something right with this one.
It's a concise song that serves as a bridge between Pressure Dub and Absence. The first version of this track was much longer and very dance-y, with many rhythms going on.
Ultimately, I decided to remove all the drums and make it much shorter. I also sent the entire track through the Mood pedal, and the variations you hear throughout the song are me tinkering with that Mood pedal.
This song very much reminds me of Lalibela, a short song near the end of Caribou's excellent album Swim.
I have never created a track as fast as Absence. The entire thing was recorded in one evening, with some minor editing done the day after.
I wanted to make a song that would be a good fit for the end of the only. Absence was recorded after all other songs had been done already, and to make it fit with the rest, I already knew the elements I wanted to feature: very slow guitars with a lot of reverb, and toms from the 808.
This track was mainly made using an Osmose Expressive E synth. This synth, which you can see demonstrated here, is a very special one. This synth has very sensitive keys, which not only move up and down but also to the left and right, allowing you to make very expressive sounds.
When you create moody electronic music like this track, it is essential (imho) that it sounds organic. People shouldn't have the feeling that they are listening to loops that are perfectly aligned on a beat. The trick to achieve this is to move things away from the exact beat and have variations each time a sound, or a set of chords, repeats.
The Expressive E synth makes it very easy to create such variations because it is so sensitive. The whole track revolves around a four-chord sequence that starts around 1:00. This chord sequence isn't a loop. I actually played it, and because I'm not a very good player, each time I go through the chords, there's slightly different pressure applied, and I make little mistakes so that it's a bit off-beat.
Another example of this is the bass that is going throughout the entire song. This bass also comes out of the Expressive E. I love the warm and tick sound it has.
The whole time, this bass is pulsating a bit: it is going softer and louder. You can hear this the most clearly starting from 4:05 when the four-chord sequence fades. With any other synth, I would have used an LFO to modulate the volume and other parameters of the bass. This would have resulted in an audible pattern. Because the Expressive E is so sensitive, I could play those variations and make it louder and softer when I felt it was right.
Yeah, this song was a joy to make, and I can see myself using the Expressive E for more songs in the future.
The album was recorded at home on my MacBook Pro, using Ableton. Felix Davis mastered it at Metropolis Studios.
If you like my album, check out these other great albums that inspired me while making Kind.
That album by Tax Shelter is recorded by my buddy Thomas and myself. I learned a lot about creating these kind of moody songs when recording that album.
I'm delighted with how the entire album turned out. This is the first time I have successfully created a coherent set of songs. The next album will probably have another sound, and I'm already experimenting with some noisy sounds as I want to keep away from the warm sound of Kind. It's time for something different now.
If you listened to Kind, please let me know your thoughts on it on X or Mastodon.
Kind isn't the first album that I've made. You can see a list of all previous albums on my music page.
]]>Next month, we'll have a new edition of Full Stack Europe, you can purchase your tickets here. We have an excellent line-up featuring Justin Jackson, Una Kravets, and people that work at companies like Meta, Google, Netlify. If you're familiar with the Laravel community, you'll also probably recognize Kai Sassnowski, Tobias Petry, and a few others! Hope to see you at the conference!
]]>To make crafting HTML emails a lot more enjoyable, the folks at Mailjet created a solution called MJML, which stands for "Mailjet Markup Language." It's an easy-to-use abstraction layer over HTML.
We have created a new package called spatie/mjml-php to easily convert MJML to HTML using PHP. If you're using Sidecar, you'll be happy to know that we've also created a package called spatie/mjml-sidecar, to convert MJML to HTML using Sidecar.
In this blog post, I'd like to introduce the package to you.
MJML looks very much like HTML but with mj
tags. Let's take a look at some valid MJML.
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text invalid-attribute>Hello World</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
In the code above, you can see that there are many mj
components that you can make use of. I won't explain them all as they are described in the extensive documentation. In those docs, you'll also find a complete example of an entire mail.
When converting the MJML to HTML, this is the result:
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-space: 0pt;
mso-table-space: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
</style>
<style type="text/css">
</style>
</head>
<body style="word-spacing:normal;">
<div style="">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">Hello World</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
Wow, that's a lot of HTML you didn't have to come up with yourself. Neat! As you can see above, some code is being added to ensure it renders well in older email clients.
The MJML provides many different options to convert MJML to HTML: there is a Node tool, a CLI tool, and also a website where you can convert MJML manually. Using one of the many plugins, you can also have auto-completion for MJML in your favorite editor.
Our newest package, spatie/mjml-php, can convert MJML to HTML using PHP. It's a handcrafted, easy-to-use wrapper around the Node tool. By using the Node tool under the hood, we can just make use of all functionality there without having to implement it all in PHP. But as a user of the PHP package, you don't need to mind all this.
The package can be installed using composer:
composer require spatie/mjml-php
Also, you should make sure the Node MJML package is available in your project.
npm install mjml
With this out of the way, you can start converting MJML.
use Spatie\Mjml\Mjml;
// let's assume $mjml contains the MJML you want to convert
$html = Mjml::new()->toHtml($mjml);
There are a couple of methods, such as beautify
, hideComments()
, minify()
to customize the conversion process.
use Spatie\Mjml\Mjml;
// let's assume $mjml contains the MJML you want to convert
$minifiedHtml = Mjml::new()->minify()->toHtml($mjml);
We also added a method that you can use to ensure the given MJML is valid.
use Spatie\Mjml\Mjml;
Mjml::new()->canConvert($mjml); // returns a boolean
Sidecar is a fantastic package that easily lets you use other programming languages besides PHP in your Laravel codebase. Under the hood, this work by executing code in another language on AWS Lambda.
We've created a package spatie/mjml-sidecar to convert MJML to HTML using Sidecar. This way, the conversion process will be done on AWS, and you don't have to have Node available on your server.
With the sidecar package installed, you should have to tack on the sidecar()
method to execute the conversion on AWS.
use Spatie\Mjml\Mjml;
// let's assume $mjml contains the MJML you want to convert
$html = Mjml::new()->sidecar()->toHtml($mjml);
At Spatie, we're always building packages not only because we like doing it but because we need them ourselves. The MJML package will be used in our paid product Mailcoach. Mailcoach is an affordable, developer- and privacy-friendly solution to send newsletters, set up email automation, and send transactional emails.
Using Mailcoach, you could already create your templates and emails using Markdown. The spatie/mjml-php package will be used to add MJML support to Mailcoach. We bet that many of our users will be happy that using MJML will make it easier to create emails that will look good to each email client.
MJML support will be added in the upcoming v7 of Mailcoach in both the hosted and self-hosted versions. We expect to ship this release in the next couple of weeks.
There are a couple of options available on our MJML package that we didn't cover in our blog post. To learn more, head over to the readme of spatie/mjml-php on GitHub.
This is one of many packages that we've made. You'll find an extensive list of Laravel and PHP packages we released previously on our company website. There's probably something there for your next project. If you like our open-source stuff, be sure to also look at our paid products that make working on open-source sustainable for our company.
]]>Behind the scenes, Oh Dear uses Postmark to send emails. Postmark will inform us whenever a notification mail results in a hard bounce. A hard bounce means that the mail won't be delivered. The most common reason for this is that the mailbox doesn't exist (anymore). This can occur when somebody changes jobs, and the work email address no longer exists.
Whenever Postmark informs us about a hard bounce, Oh Dear will determine which team that email belongs to. It will send a mail to the owner of the Oh Dear team asking to correct the email address or to remove the member from the team.
For most cases, this is fine, but what if it is the email address of the team owner itself that bounced? In this case, we obviously can't mail the team owner anymore.
Postmark recently introduced a new small service called Rebound. When installed on a site, Rebound will display a warning to a user whose email resulted in a hard bounce.
It is dead easy to set up. The Rebound site has a simple wizard to customize the behavior and looks.
The wizard generates a simple script, which you can use in your project, and inject the authenticated user's email.
We've now set this up at Oh Dear, so whenever a user logs in whose email has bounced, a friendly warning like this one is displayed.
Very nice!
]]>:target
selector
Read more]]>
Using the default instructions Prometheus and Grafana can be daunting to set up. That's why package documentation also has friendly, detailed instructions on how to get started with Prometheus and Grafana.
Let's dive in!
Before diving into how you can use the package, let's first look at how visualizing metrics can help you understand what's happening in your app.
Here's a screenshot of a simple Laravel app that has four queues.
You can see that there are currently some jobs in all queues. Are these numbers healthy and normal for my application? To answer this question, more than simple point-in-time numbers are needed.
Oh Dear is a batteries-included monitoring service I've built. Under the hood, we use Laravel Horizon to power the queues. We use our spatie/laravel-prometheus package to export the workload of the queues to Prometheus and plot a graph of that data using Grafana.
Here's a graph of the workload of our queue for the past 30 minutes. To give too much away about our operations. I've removed the actual job counts on the left side of the graph and the legend with queue names under the graph.
In the screenshot, every colored line is the workload of a queue. You can see that our queues have a certain "rhythm." Poetically said this is a visualization of our queues' heartbeat or breathing rhythm.
A visualization like this makes it easy to see if something weird is or is happening. Here's a screenshot of our queues, but this time for the past 12 hours. You can see that something was off around 11 am.
Let's also take a look at a scale of 7 days. In this screenshot, you can again see the breathing rhythm of our queues.
Plotting out the historical load on Horizon gives us a good picture of the health of the queues. Of course, these graphs aren't everything. You still need a monitoring solution like Flare to detect any exceptions that happen inside the jobs.
In the example above, we only plotted technical data (the workload of queues), but seeing trends in business-y data (the number of users, subscriptions in your SaaS, ...) can be very helpful in making business decisions.
Let's install Laravel Prometheus in your app. This can be done with Composer. No surprises there!
composer require spatie/laravel-prometheus
Next up, we can complete the installation with this artisan command.
php artisan prometheus:install
This command will copy and register this service provider in app/Providers/PrometheusServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Spatie\Prometheus\Collectors\Horizon\CurrentMasterSupervisorCollector;
use Spatie\Prometheus\Collectors\Horizon\CurrentProcessesPerQueueCollector;
use Spatie\Prometheus\Collectors\Horizon\CurrentWorkloadCollector;
use Spatie\Prometheus\Collectors\Horizon\FailedJobsPerHourCollector;
use Spatie\Prometheus\Collectors\Horizon\HorizonStatusCollector;
use Spatie\Prometheus\Collectors\Horizon\JobsPerMinuteCollector;
use Spatie\Prometheus\Collectors\Horizon\RecentJobsCollector;
use Spatie\Prometheus\Facades\Prometheus;
class PrometheusServiceProvider extends ServiceProvider
{
public function register()
{
/*
* Here, you can register all the exporters that you
* want to export to Prometheus
*/
Prometheus::addGauge('my_gauge', function () {
return 123.45;
});
/*
* Uncomment this line if you want to export
* All Horizon metrics to Prometheus
*/
// $this->registerHorizonCollectors();
}
public function registerHorizonCollectors(): self
{
Prometheus::registerCollectorClasses([
CurrentMasterSupervisorCollector::class,
CurrentProcessesPerQueueCollector::class,
CurrentWorkloadCollector::class,
FailedJobsPerHourCollector::class,
HorizonStatusCollector::class,
JobsPerMinuteCollector::class,
RecentJobsCollector::class,
]);
return $this;
}
}
Using the Prometheus
facade, values can be registered to be exported to Prometheus. In the code above, you can see that call addGauge
to the facade. A "gauge" is one of the available metric types in Prometheus: it's a value that can both go up and down.
The code above is meant as a generic example. If you want to export your user count to Prometheus, you'll add code like this.
use App\Models\User;
use Spatie\Prometheus\Facades\Prometheus;
Prometheus::addGauge('user_count', function () {
return User::count();
});
There are many more options to define gauges (you can define help texts, labels, ...) Learn more about all these options in our docs.
How will this data be transported to Prometheus? Well, the package adds an endpoint /prometheus
to your app, where all registered gauges and values will be rendered in a format that Prometheus understands.
Here's what such output could look like.
# HELP demo_app_user_count
# TYPE demo_app_user_count gauge
demo_app_user_count 5424
You can configure Prometheus to scrape that /prometheus
endpoint periodically, or you could use the Grafana agent to push this information to Prometheus/Grafana (I'll talk more about setting this up in the next chapter of this blog post).
In addition to defining your own metrics, you can also use the package to export all metrics around Horizon, such as the number of jobs waiting in every queue, how many processes there are per queue, and much more.
The package comes with built-in support for exporting Horizon metrics. The only thing you need to do is to uncomment the registerHorizonCollectors
line in the PrometheusServiceProvider
.
class PrometheusServiceProvider extends ServiceProvider
{
public function register()
{
// define other metrics here...
$this->registerHorizonCollectors();
}
public function registerHorizonCollectors(): self
{
Prometheus::registerCollectorClasses([
CurrentMasterSupervisorCollector::class,
CurrentProcessesPerQueueCollector::class,
CurrentWorkloadCollector::class,
FailedJobsPerHourCollector::class,
HorizonStatusCollector::class,
JobsPerMinuteCollector::class,
RecentJobsCollector::class,
]);
return $this;
}
}
When we now look at the output on the /prometheus
endpoint, you'll see an output like this. For this example, I've defined four queues on Horizon and dispatched a couple of jobs.
# HELP demo_app_horizon_current_processes Current processes of all queues
# TYPE demo_app_horizon_current_processes gauge
demo_app_horizon_current_processes{queue="queue-1"} 1
demo_app_horizon_current_processes{queue="queue-2"} 1
demo_app_horizon_current_processes{queue="queue-3"} 1
demo_app_horizon_current_processes{queue="queue-4"} 1
# HELP demo_app_horizon_current_workload Current workload of all queues
# TYPE demo_app_horizon_current_workload gauge
demo_app_horizon_current_workload{queue="queue-1"} 494
demo_app_horizon_current_workload{queue="queue-2"} 197
demo_app_horizon_current_workload{queue="queue-3"} 291
demo_app_horizon_current_workload{queue="queue-4"} 949
# HELP demo_app_horizon_failed_jobs_per_hour The number of recently failed jobs
# TYPE demo_app_horizon_failed_jobs_per_hour gauge
demo_app_horizon_failed_jobs_per_hour 0
# HELP demo_app_horizon_jobs_per_minute The number of jobs per minute
# TYPE demo_app_horizon_jobs_per_minute gauge
demo_app_horizon_jobs_per_minute 3
# HELP demo_app_horizon_master_supervisors The number of master supervisors
# TYPE demo_app_horizon_master_supervisors gauge
demo_app_horizon_master_supervisors 1
# HELP demo_app_horizon_recent_jobs The number of recent jobs
# TYPE demo_app_horizon_recent_jobs gauge
demo_app_horizon_recent_jobs 3908
# HELP demo_app_horizon_status The status of Horizon, -1 = inactive, 0 = paused, 1 = running
# TYPE demo_app_horizon_status gauge
demo_app_horizon_status 1
# HELP demo_app_user_count
# TYPE demo_app_user_count gauge
demo_app_user_count 5424
There are multiple ways to set up Prometheus and Grafana. You could install both pieces of software on your server and do some configuration to make it all work. The steps for this are outlined in our docs.
The easiest way to get started using Prometheus and visualizing data via Grafana is by creating a free account on grafana.com and using the hosted Grafana instance.
During this process, you'll install the Grafana agent, which will read the metrics on your /prometheus
endpoint, and push them to Grafana.com.
I've prepared a little demo application that you could use to follow along with this part of the post. The demo application is a vanilla Laravel app, but with Laravel Prometheus and Horizon preinstalled and a command to dispatch some jobs on the four horizon queues.
First, you must create a free account on Grafana.com. Once your account has been created, you'll be on your account dashboard. There, you should launch your Grafana instance by clicking the "Launch" button.
At this point, you'll be redirected to your Grafana instance. There, you must go to "Connections" and add a new connection of type "Hosted Prometheus Metrics."
When creating a new connection, choose "Via Grafana Agent."
Next, follow the wizard, install the agent, and create a new config.
Follow the steps to create the config file and start the agent on your server. To keep the agent running, you might use something like Supervisord (Laravel Forge users can create a deamon)
In the scrape_configs
key of the config, you should add a job to scrape the /prometheus
endpoint of your Laravel application. For example:
global:
scrape_interval: 10s
configs:
- name: hosted-Prometheus
scrape_configs:
- job_name: laravel
scrape_interval: 10s
metrics_path: /prometheus
static_configs:
- targets: ['your-app.com']
remote_write:
- url: <filled in by the wizard>
basic_auth:
username: <filled in by the wizard>
password: <filled in by the wizard>
Of course, you should replace your-app.com
with the domain of your application.
Once you've configured the agent, you can create a new dashboard. Head over to "Dashboards" and create a new dashboard.
On that screen, click "+ Add Visualization."
Next, click your hosted Prometheus instance as the source.
You should see all metrics being scraped from your Laravel application in the metric dropdown.
Let's pick the horizon's current workload; ensure that we're adding a tile of type "Type series," and optionally, you can add a title.
Also, to format the legend that will appear on the graph better, use {{queue}}
to format the legend.
Now save your tile. If you configured everything correctly, and if jobs are being processed on your queue, you should, after a while, see a graph like this one. It shows the trend of waiting jobs per queue.
From here on, you can create an expanded dashboard with more tiles, as you would typically do in Grafana. For more information on creating dashboards and tiles, please refer to the Grafana documentation.
In the blog post, you've learned that visualizing metrics has many benefits. Our Laravel Prometheus package can help you export your custom and Horizon metrics to Prometheus. This blog post covered only some of the options, so be sure to check out the extensive docs.
Currently, I'm using Prometheus / Grafana on all our SaaS that heavily realy on queues: Flare, Mailcoach Cloud, and Oh Dear.
This isn't the first package we've made. Please look at this extensive list of Laravel and PHP packages we've made before. I'm sure there's something there for your next project. If you want to support our open-source efforts, consider picking up one of our paid products or subscribe at Mailcoach and/or Flare.
]]>