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.
]]>Here’s what the scheduled jobs card looks like.
In this blog post, I'd like to tell you more about these cards.
Oh Dear is a powerful monitoring service that my buddy Mattias and I have built. Of course, we provide uptime monitoring, but it goes much further than that. We don’t only monitor the homepage but crawl your entire site and can report all broken links. Oh Dear can also monitor if your scheduled jobs run on time, if Horizon is running, and much much more.
Laravel Pulse is a first-party package that can display a dashboard with information surrounding usage and performance of your Laravel app. Here’s how a default installation looks like.
Out of the box, it comes with many so-called cards that show a specific metric of your app. One of the cool things about Laravel Pulse is that it is very extendible. Here’s a video by Christoph Rumpel that shows how to create your own cards.
Our new ohdearapp/ohdear-pulse package contains three beautiful Pulse cards. All of these cards fetch their data from the Oh Dear API.
The first one displays if your site is up and recent response times.
Of course this card, and all others, also support dark mode.
As said above, Oh Dear can monitor if the scheduled jobs of your Laravel app run on time. Using the spatie/laravel-schedule-monitor package, you can sync the schedule of your app to Oh Dear.
The cron Pulse card displays when your scheduled jobs have run for the last time and if they ran on time. Very powerful stuff if you ask me.
The last card can display any broken links of your app. This card is powered by Oh Dear’s broken links check, which crawls your entire site.
To use these cards, you have to pull in the package via composer.
composer require ohdearapp/ohdear-pulse
In your config/services.php
file, add the following lines:
'oh_dear' => [
'pulse' => [
'api_key' => env('OH_DEAR_API_TOKEN'),
'site_id' => env('OH_DEAR_SITE_ID'),
],
],
You can create an API token on the "API Tokens" page at Oh Dear. You'll find the site ID on the "Settings" page of a site on Oh Dear.
You can add the cards to your Pulse dashboard by first publishing the Pulse's dashboard view:
php artisan vendor:publish --tag=pulse-dashboard
Next, add the cards to the resources/views/vendor/pulse/dashboard.blade.php
file:
<x-pulse>
<livewire:ohdear.pulse.uptime cols="4" />
<livewire:ohdear.pulse.cron cols="8" />
<livewire:ohdear.pulse.brokenLinks cols="8" />
{{-- Add more cards here --}}
</x-pulse>
Laravel Pulse is a really neat way of keeping an eye on the performance and usage of your app. With Oh Dear’s beautiful cards, it becomes even more powerful.
You’ll find the code in this repo on GitHub.
Oh Dear has a free 10 day trial (no credit card needed), to test out the cards.
]]>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.
]]>