Sending Laravel Slack Notifications with a webhook URL

Laravel Notifications support a number of channels, such as email, SMS and Slack. If you follow their documentation for sending Slack notifications you’ll need to setup a bot user with an OAuth token.

If you were creating a new Slack ‘app’ to receive messages this would be the way to go. In my case though I was refactoring some Logging which used the Slack channel with a webhook URL setup. The Slack interface didn’t allow me to setup or find the bot user/OAuth token for an existing App.

In order to use the Notifications class with a webhook I needed to send a simpler message structure than what you see in the docs and use slightly different service configuration.

Slack Service Config

In config/services.php

<?php

return [
    'slack' => [
        'webhook_url' => env('LOG_SLACK_WEBHOOK_URL'),

        /*
       Use a webhook_url instead of the bot_user_oauth_token structure

        'notifications' => [
             'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
             'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
        ],
       */
    ],
];

Instead of calling the text, headerBlock, contextBlock type methods I just called content()

<?php

namespace App\Notifications;

use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;

class CafeNotification extends Notification
{
    public function via(object $notifiable): array
    {
        return ['slack'];
    }

    public function toSlack(object $notifiable): SlackMessage
    {
        return (new SlackMessage)
            ->content($theMessageGoesHere)
            ->unfurlLinks(false);
    }
}

This was enough to get simple notifications going without having to recreate my Slack app in order to setup a bot user and token.

Upgrading to Laravel 11 with Redis and CI

I’m in the process of upgrading my coffee discovery app HadCoffee to Laravel 11. Shift did most of the work, but you always have to tweak a few things.

I found that although my test suite passed locally, the Chipper CI builds were still failing on tests that touched Redis, or cache (which uses Redis in my case).

I needed to update the env variables to connect to Redis. Maybe you do too if you’re on this page 🙂

I’m using the Predis client.

REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Laravel Model Timestamps without an updated_at

Laravel makes it easy to add created_at and updated_at fields to your models. Just include the ->timestamps() shorthand in the migration, and use public $timestamps = true on the model.

Sometimes though you’ll have high-volume models such as an event log that record immutable things that will never be updated. Storing a redundant updated_at can be a bit wasteful if you’ll never need it, and it tells you nothing new.

It’s easy to customise the behaviour, but perhaps a little hard to discover. I missed it in my searching until Jess Archer told me it was supported.

In the migration

Define your migration like so:

<?php
// ...

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('my_table', function(Blueprint $table) {
            $table->smallIncrements('id');
            $table->timestamp('created_at', 0)->nullable();
            // define created_at on its own
        });
    }
}

In the model

By default Laravel will attempt to write to the updated_at column. If you don’t have one it will error.

You can easily disable it like so:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class MyModel extends Model
{
    public $timestamps = true;

    const UPDATED_AT = null;

    //...
}

Done 🙂

Using S3 Lifecycle rules with Spatie Laravel Backups

AWS S3 has a feature called Lifestyle Rules that let you define rules to run a range of transitions on your objects, such as changing their storage class or expiring (deleting) them.

This can be useful to save costs and clean up your object list by removing objects you don’t need to keep forever. You can lean on AWS for the expiry logic without having to program scheduled events to clean up for you.

Lifecyle rules can be targeted to an entire bucket, to objects with a path prefix, or to objects with a particular Tags.

Spatie Laravel Backup is a popular Laravel package for taking site/database backups.

It can be configured to write to various ‘disks’. If you’re using the S3 disk it is possible to Tag those backups for targeting by the Lifecycle rule.

Creating the rules in S3 and targeting a tag

S3 Console screenshot showing Management lifecycle rules

In my case I will be targeting a tag called fadeout with a value of true.

This rule will transition objects to the Standard Infrequent Access storage class after 30 days, and then delete them after 60 days. It also only applies to objects over 150kB.

Tagging backups that Laravel Backup uploads

To have your backup objects tagged in order for the lifecycle rule to take effect you’ll need to an an option to your Laravel fileysystems.php config file.

Assuming you are using the s3 disk, you’ll need to add a backup_options value to your S3 config array like so:

's3' => [
  'driver' => 's3',
  'key' => env('AWS_ACCESS_KEY_ID'),
  'secret' => env('AWS_SECRET_ACCESS_KEY'),
  'region' => env('AWS_DEFAULT_REGION'),
  'bucket' => env('AWS_BUCKET'),
  'url' => env('AWS_URL'),
  'backup_options' =>
     ['Tagging' => 'fadeout=true', 'visibility'=>'private'],
],

Backups are private by default, but because we’re overriding the options array we need to specify it again.
The format of the Tagging key is url encoded as shown.
The Tagging and visibility keys are case sensitive as shown.

With that in place you can keep your recent backups without cluttering your bucket 🙂

Check Australian Business Number (ABN) in PHP and Laravel

Note: Newer versions of Laravel now have a different interface for creating custom validation rules. See the ValidationRule class. This post hasn’t been updated yet. (2024).

ABNs use a checksum, so their basic format can be validated. There is also an API to check that the ABN also corresponds to a real business, but as a preliminary check this function will tell you that the format is correct.

See the rules behind the check here.

<?php

// Remove spaces from an ABN string before passing to function.

function validAbnFormat(int $abn): bool
{
  if (strlen($abn) !== 11) {
    return false;
  }

  $nums = array_map('intval', str_split($abn, 1));
  $nums[0] -= 1;

  $weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];

  foreach($nums as $pos => $num) {
    $weight = $weights[$pos];
    $nums[$pos] = $num * $weight;
  }

  $sum = array_sum($nums);
  return $sum % 89 === 0;
}

Creating a Laravel Validation Rule

If you would like to validate ABNs in a Laravel application you can register a custom validation rule that you can use in Form Requests or Validators.

Create the new rule class using artisan

php artisan make:rule Abn

Then use a variation of the function above to validate the ABN. The Abn class should look like this:

<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;

class Abn implements Rule
{
    public function __construct() {}

    /**
     * Determine if the validation rule passes.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @return bool
     */
    public function passes($attribute, $abn)
    {
        $abn = str_replace(' ', '', $abn);

        if (strlen($abn) !== 11) {
            return false;
        }
        
        $nums = array_map('intval', str_split($abn, 1));
        $nums[0] -= 1;
        
        $weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19];
        
        foreach($nums as $pos => $num) {
            $weight = $weights[$pos];
            $nums[$pos] = $num * $weight;
        }
        
        return array_sum($nums) % 89 === 0;
    }

    /**
     * Get the validation error message.
     */
    public function message(): string
    {
        return 'Invalid ABN';
    }
}

You can then use this validation rule by importing the class and adding it to your set of rules for an input.

<?php

use App\Rules\Abn;

// ...

$validator = Validator::make(
   $input->all(),
   ['abn' => ['required', new Abn]]
);

Testing Form Request Validation in Laravel

I recently got stuck writing a test for a Controller which uses a custom form request for validation and authorization. Though the form request worked correctly for real requests in the browser, it was being ignored from the Feature test. In other words the Controller action would run even with invalid test input and even when the form request was hard-coded to specifically block authorization.

Incorrect Approach

I had been creating the form request, instantiating the Controller I wanted to test and sending the request. This bypassed the checks, presumably because the middleware that triggers validation isn’t run in this context.

function test_invalid_request()
{
  $request = CafeStoreRequest::create('cafe', 'POST', ['cafe_name' => '']); //should fail
  $controller = new App\Http\Controllers\CafeController();
  $response = $controller->store($request);

  $response->assertSessionHasErrors(['cafe_name']);
}

What Worked

Instead of manually creating a controller, I needed to route the request through the HTTP layer so that the middleware would do its thing and trigger the appropriate error.

function test_invalid_input()
{
  $response = $this->post(route('cafe.store', ['cafe_name' => '']));
  $response->assertSessionHasErrors(['cafe_name']);
}

Deleting with Axios and Laravel

With a RESTful Controller you will typically have methods to show, list, update, edit and destroy (delete) the entity in question. The controller methods map to HTTP methods (verbs) such as GET, POST, PUT and DELETE.

Not all browsers support all of the HTTP methods. The PUT and DELETE requests might actually be sent as a POST request, but with the _method parameter included to tell Laravel’s routing system what to do with the request. When that hint is present Laravel will use it to determine the type of request, rather than the actual HTTP request method.

If you are making these HTTP requests with Axios then you might find some of the calls are failing with a 405 Method Not Allowed error.

This is due to the way Axios sends POST, PUT and DELETE requests. You may need to actually POST the request and include the _method hint in the request data instead.

Example JavaScript

axios.post('/myentity/839', {
  _method: 'DELETE'
})
.then( response => {
   //handle success
})
.catch( error => {
   //handle failure
})

Laravel’s Signed URLs Breaking with Nginx

Laravel includes a feature to sign URLs so that publicly accessible routes can be visited knowing the query string hasn’t been tampered with.

Laravel uses this for email verification links by default. I ran into a problem where signed URLs were always failing (throwing the 403 exception) in my production environment. The same code worked locally.

In turns out that this was a result of the way the package uses query string parameters to build the signature. My production nginx configuration was creating a different parameter signature to what Laravel was expecting. This was fine for unsigned URLs as the routing still worked, but it did mean that the ‘signed’ middleware would always fail.

The solution was just to alter my sites nginx config like this:

# Nope
try_files $uri $uri/ /index.php?q=$uri&$args;

# Yes
try_files $uri $uri/ /index.php?$query_string;

Now Laravel’s request object receives the same query parameters and the signed URL signature will match.

Laravel Eloquent gotcha using With and Find

The Eloquent ORM in Laravel makes it easy to pull in related models using the ::with() method.  It’s very useful and can solve the N+1 Query problem that can occur when accessing related models in a loop from the parents records.

<?php
$data = MyParentModel->with('ChildModel');

You’d commonly want to use this when accessing a particular parent model using the ::find() or ::findOrFail() method. The trap can be that with() must be called before findOrFail().

If you chain methods and call findOrFail() first it will be ignored and your result set will include all parent rows!

<?php
$data = MyParentModel->with('ChildModel')->findOrFail($parent_id);

Laravel 5 Environment Config

Laravel 4 used a function that checked the servers hostname to determine the environment. Laravel 5 simplifies environment detection by having a .env file present in your project root.

.env.example is ignored by Laravel’s detection. You can fill it with the keys your application expects to act an an example for your own file.

.env is for the current environment

Add .env to your .gitignore file. It’s got to stay out of your repo so you don’t overwrite it each time you pull.
Copy/Rename .env.example to .env for each environment.

set the APP_ENV value within your .env file to tell Laravel where it’s running.

APP_ENV=local
APP_DEBUG=true
APP_KEY=123abc...
$app->environment(); //get the current environment
if($app->environment('local', 'staging')) { }  //test if local or staging env

You can retrieve your .env config values with the env() function.


$cfgValue = env('MY_SWEETAS_VARIABLE');