Loading...

My process for writing Laravel packages

David Carr

12 min read - 10th Nov, 2023

Laravel packages

Introduction

I was asked recently how I started when writing new Laravel packages.

In this post I'm going to build a new package called Laravel Tags it will take a string and replace placeholder tags kinda of like shortcodes.

For example, have a blog post that contains tags [year] or [appName] and have them replaced in the content.

So [year] would be replaced with the actual year. [appName] would be replaced with the Laravel application name.

Its usage will be:

$content = "This post was made in [year] on the application [appName]";
$content Tags::get($content);
//$content would now contain This post was made in 2023 on the application Laravel

How I organise my packages

On my machine, I have a package folder. From here I make a folder for each package.

Laravel Packages

I then load these into my projects using composer.json

In the require section load the package by its vendor name followed by the package name using @dev to load the main version.

"dcblogdev/laravel-tags": "@dev"

Next in order to load this, I tell composer where to find the files locally using a repository path:

"repositories": [
    {
        "type": "path",
        "url": "../../packages/laravel-tags"
    }
]

In this case, I need composer to look two folders above the current Laravel project.

Now run composer update to install the package into your Laravel project.

What’s good about this approach is you can do this with third-party packages. This is useful as you can run tests and add features that you plan to raise pull requests for.

Build package structure

You can use package starter kits such as https://github.com/spatie/package-skeleton-laravel

I tend to build packages from scratch or copy one of my existing packages and remove what I don’t need.

In this post, I'm imagining building a package called laravel-tags

The folder structure will be:

https://github.com/dcblogdev/laravel-tags

Package Structure

license.md

For the license, I use The MIT license which allows users to freely use, copy, modify, merge, publish, distribute, sublicense, and sell the software and its associated documentation.

this file contains:

# The license

The MIT License (MIT)

Copyright (c) 2023 dcblogdev

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

composer.json

I'll start by adding a composer.json file that contains:

{
  "name": "dcblogdev/laravel-tags",
  "description": "A Laravel package for adding tags to your content",
  "license": "MIT",
  "authors": [
    {
      "name": "David Carr",
      "email": "dave@dcblog.dev",
      "homepage": "https://dcblog.dev"
    }
  ],
  "homepage": "https://github.com/dcblogdev/laravel-tags",
  "keywords": [
    "Laravel",
    "Tags"
  ],
  "require": {
    "php": "^8.1"
  },
  "require-dev": {
    "orchestra/testbench": "^8.0",
    "pestphp/pest": "^v2.24.2",
    "pestphp/pest-plugin-type-coverage": "^2.4",
    "laravel/pint": "^1.13",
    "mockery/mockery": "^1.6"
  },
  "autoload": {
    "psr-4": {
      "Dcblogdev\\Tags\\": "src/",
      "Dcblogdev\\Tags\\Tests\\": "tests"
    }
  },
  "autoload-dev": {
    "classmap": [
      "tests/TestCase.php"
    ]
  },
  "extra": {
    "laravel": {
      "providers": [
        "Dcblogdev\\Tags\\TagsServiceProvider"
      ],
      "aliases": {
        "Tags": "Dcblogdev\\Tags\\Facades\\Tags"
      }
    }
  },
  "config": {
    "allow-plugins": {
      "pestphp/pest-plugin": true
    }
  },
  "scripts": {
    "pest": "vendor/bin/pest --parallel",
    "pest-coverage": "vendor/bin/pest --coverage",
    "pest-type": "vendor/bin/pest --type-coverage",
    "pint": "vendor/bin/pint"
  }
}

Breaking this down, first, the package needs a name in the convention of vendor name and package name dcblogdev/laravel-tags in my case the vendor name is dcblogdev and the package name is laravel-tags

In a require-dev section, I import these third-party packages for development only.

  • testbench package allows you to use Laravel conventions for testing.

  • pest is my testing framework of choice

  • pint to apply styling conventions to my codebase.

"orchestra/testbench": "^8.0",
"pestphp/pest": "^v2.24.2",
"pestphp/pest-plugin-type-coverage": "^2.4",
"laravel/pint": "^1.13",
"mockery/mockery": "^1.6"

Inside autoload to any folders that you want composer to autoload. I have a folder called src which will contain the classes and tests for all the tests

One important aspect of this package name I'll often give the package a nickname to use so instead of using laravel-tags I'll use tags

I'll do this by using Tags in any class namespaces and creating an alias:

"extra": {
  "laravel": {
    "providers": [
      "Dcblogdev\\Tags\\TagsServiceProvider"
    ],
    "aliases": {
      "Tags": "Dcblogdev\\Tags\\Facades\\Tags"
    }
  }
}

Tests

Now I will create 2 folders:

  1. src

  2. tests

Inside tests I'll create these files:

TestCase.php

<?php

namespace Dcblogdev\Tags\Tests;

use Dcblogdev\Tags\TagsServiceProvider;
use Orchestra\Testbench\TestCase as Orchestra;

class TestCase extends Orchestra
{
    protected function getPackageProviders($app)
    {
        return [
            TagsServiceProvider::class,
        ];
    }

    protected function getEnvironmentSetUp($app)
    {
        $app['config']->set('database.default', 'mysql');
        $app['config']->set('database.connections.mysql', [
            'driver' => 'sqlite',
            'host' => '127.0.0.1',
            'database' => ':memory:',
            'prefix' => '',
        ]);
    }

    protected function defineDatabaseMigrations()
    {
        $this->loadLaravelMigrations();

        $this->loadMigrationsFrom(dirname(__DIR__).'/src/database/migrations');
    }
}

The methods getEnviromentSetup and defineDatabaseMigrations are not needed by default. They are only required if you need to use a database and load migrations.

Inside getPackageProviders the main package service provider is loaded.

Now create a file called Pest.php

<?php

use Dcblogdev\Tags\Tests\TestCase;

uses(TestCase::class)->in(__DIR__);

This file calls a uses() function to load the testcase::class I'll use __DIR__ inside the in() method to make Pest use the TestCase class in the root of this directory.

Inside the package root create a phpunit.xml file containing:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
  <source>
    <include>
      <directory suffix=".php">src/</directory>
    </include>
  </source>
  <testsuites>
    <testsuite name="Test">
      <directory suffix="Test.php">./tests</directory>
    </testsuite>
  </testsuites>
  <php>
    <env name="APP_KEY" value="base64:2fl+Ktv64dkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10="/>
  </php>
</phpunit>

This sets the location for tests to read from, it's rare this file will need to be changed from this default.

I set an APP_KEY standard for running tests, its value is not important.

Pint

To set up find create a folder in the package root called pint.json I use the Laravel preset:

{ 
    "preset": "laravel" 
}

ReadMe

inside the project root create a file called readme.md to document the project, I typically use this format:

# Laravel Tags

Explain what the package does.

# Documentation and install instructions 
[https://docs.dcblog.dev/docs/laravel-tags](https://docs.dcblog.dev/docs/laravel-tags)

## Change log

Please see the [changelog][3] for more information on what has changed recently.

## Contributing

Contributions are welcome and will be fully credited.

Contributions are accepted via Pull Requests on [Github][4].

## Pull Requests

- **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date.

- **Consider our release cycle** - We try to follow [SemVer v2.0.0][5]. Randomly breaking public APIs is not an option.

- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.

## Security

If you discover any security related issues, please email dave@dcblog.dev email instead of using the issue tracker.

## License

license. Please see the [license file][6] for more information.

[3]:    changelog.md
[4]:    https://github.com/dcblogdev/laravel-tags
[5]:    http://semver.org/
[6]:    license.md

ServiceProvider

Inside src create the service provider in my case TagsServiceProvider.php

<?php

namespace Dcblogdev\Tags;

use Illuminate\Support\ServiceProvider;

class TagsServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->configurePublishing();
    }

    public function configurePublishing(): void
    {
        if (! $this->app->runningInConsole()) {
            return;
        }

        $this->publishes([
            __DIR__.'/../config/tags.php' => config_path('tags.php'),
        ], 'config');
    }

    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/tags.php', 'tags');

        // Register the service the package provides.
        $this->app->singleton('tags', function () {
            return new Tags;
        });
    }

    public function provides(): array
    {
        return ['tags'];
    }
}

Inside the boot method list the methods, to begin with I only have one called configurePublishing This will publish any files defined in this page and will only run when the class is ran from a CLI

The register method allows a published config file to be merged in with a local ./config/tags.php config file

And set up the main class to be instantiated.

Facades

If you want to make use of facade ie have static called to a class that would normally be instantiated.

Inside src create a folder called facades and your class such as Tags.php

<?php

namespace Dcblogdev\Tags\Facades;

use Illuminate\Support\Facades\Facade;

class Tags extends Facade
{
    protected static function getFacadeAccessor(): string
    {
        return 'tags';
    }
}

This allows for calling the package's class and method in one show:

Tags::get();

Notice I did not have to instantiate a class in order to call its methods.

Building the Tags package

Now I have the basic structure in place I can concentrate on building the package functionality.

I will now create a Tags class this is the main class that will provide the modify any content I provide to it.

Inside src create a new class call it Tags.php

Set the namespace and class definition.

Next, I'll create a method called get that will accept a string of content.

<?php

namespace Dcblogdev\Tags;

class Tags
{
    public function get(string $content): string
    {
        //Current year
        $content = str_replace('[year]', date('Y'), $content);

        //Name of website
        $content = str_replace('[appName]', config('app.name'), $content);

        return $content;
    }
}

Inside get I use str_replace to replace tags with a value and finally return the modified content string.

This is very simple on purpose, lets add another tag [youtube url-here]

This youtube tag expects a URL of a video to be passed, and also a width and height can be passed.

This will return the embed code needed to play a youtube video.

//youtube embeds
$content = preg_replace_callback("(\[youtube (.*?)])is", function ($matches) {
    $params = $this->clean($matches);

    //if key exits use it
    $video  = $params['//www.youtube.com/watch?v'];
    $width  = ($params['width'] ?? '560');
    $height = ($params['height'] ?? '360');

    return "<iframe width='$width' height='$height' src='//www.youtube.com/embed/$video' frameborder='0' allowfullscreen></iframe>";
}, $content);

Now this is getting a little more interesting let's add our tests.

Create a file called TagsTest.php inside the tests folder.

Here's 2 simple tests that confirm [year] and [appName] get replaced as expected.

use Dcblogdev\Tags\Facades\Tags;

test('[year] sets the year', function () {
    $year = date('Y');
    $response = Tags::get('[year]');

    expect($response)->toBe($year);
});

test('[appName] sets the name of the app', function () {
    $response = Tags::get('[appName]');

    expect($response)->toBe('Laravel');
});

Next here is a test to confirm a YouTube video can be embedded:

test('can embed youtube videos', function () {
    $response = Tags::get('[youtube https://www.youtube.com/watch?v=cAzwfFPmghY]');

    expect($response)
        ->toBe("<iframe width='560' height='360' src='//www.youtube.com/embed/cAzwfFPmghY' frameborder='0' allowfullscreen></iframe>");
});

This requires more than a simple find and replace based on a tag now. The tag now has a wildcard so [youtube https://www.youtube.com/watch?v=ehLx-jO1LF0]

needs to be extract the id of the video from the url, also needs to handle other parms being passed.

Looking over the snippet for YouTube

//youtube embeds
$content = preg_replace_callback("(\[youtube (.*?)])is", function ($matches) {
    $params = $this->clean($matches);

    //if key exits use it
    $video  = $params['//www.youtube.com/watch?v'];
    $width  = ($params['width'] ?? '560');
    $height = ($params['height'] ?? '360');

    return "<iframe width='$width' height='$height' src='//www.youtube.com/embed/$video' frameborder='0' allowfullscreen></iframe>";
}, $content);

Notice this is calling a method called clean and passing in an array of $matches

Open The Tags.php class

Let's add 2 private methods clean and removeCharsFromString

private function clean(array|string $data): array
{
    // Check if $data is an array and extract the string to be processed.
    $stringToProcess = is_array($data) && isset($data[1]) ? $data[1] : $data;

    // Ensure that the stringToProcess is actually a string.
    if (!is_string($stringToProcess)) {
        // Handle error or return an empty array
        return [];
    }

    $parts = explode(' ', $stringToProcess);
    $params = [];

    foreach ($parts as $part) {
        if (!empty($part)) {
            if (str_contains($part, '=')) {
                [$name, $value] = explode('=', $part, 2);
                $value = $this->removeCharsFromString($value);
                $name = $this->removeCharsFromString($name);
                $params[$name] = $value;
            } else {
                $params[] = $this->removeCharsFromString($part);
            }
        }
    }

    return $params;
}

private function removeCharsFromString(string $value): string
{
    $search = ['http:', 'https:', '&quot;', '&rdquo;', '&rsquo;', '&nbsp;'];
    return str_replace($search, '', $value);
}

the clean method takes an array or string and will removes anything inside removeCharsFromString and will separate something=value into ['something' = 'value']

Adding a few more tests:

test('can embed youtube videos', function () {
    $response = Tags::get('[youtube https://www.youtube.com/watch?v=cAzwfFPmghY]');

    expect($response)
        ->toBe("<iframe width='560' height='360' src='//www.youtube.com/embed/cAzwfFPmghY' frameborder='0' allowfullscreen></iframe>");
});

test('can embed youtube videos with width and height', function () {
    $response = Tags::get('[youtube https://www.youtube.com/watch?v=cAzwfFPmghY width=100 height=100]');

    expect($response)
        ->toBe("<iframe width='100' height='100' src='//www.youtube.com/embed/cAzwfFPmghY' frameborder='0' allowfullscreen></iframe>");
});

test('can embed youtube videos with height', function () {
    $response = Tags::get('[youtube https://www.youtube.com/watch?v=cAzwfFPmghY height=100]');

    expect($response)
        ->toBe("<iframe width='560' height='100' src='//www.youtube.com/embed/cAzwfFPmghY' frameborder='0' allowfullscreen></iframe>");
});

These will confirm all tags work as expected.

Publish to GitHub

Create a new repo on Github https://github.com/new

Enter a repository name in my case laravel-tags

I don't change any other settings and click Create Repository

I don't want anything committed to the repo, I want it empty so I can upload it from my machine.

Git setup

Now go back to your codebase and initialse git in the terminal at the project root type:

git init

This will initalise GIT,

Next, add and commit your files

git add .
git commit -m 'first commit

Can link your local GIT repo to the GitHub repo replace dcblogdev with your GitHub username and laravel-tags with the name of your package.

git remote add origin git@github.com:dcblogdev/laravel-tags.git

Push up your code using:

git push -u origin main

From now on you can push up using just git push

Create a release on GitHub

Once you're happy with the package and ready to publish a release follow these steps:

To to the releases page of your project on GitHub and click create release or use the following URL (replace dcblogdev/laravel-tags with your version)

https://github.com/dcblogdev/laravel-tags/releases/new

Enter a tag I prefer to start my packages with v1.0.0 then enter your release notes and click attach binaries to upload a zip of your package and finally press Publish release This will publish a new release.

Using GitHub CLI

This is a long process, I prefer to create a release from the terminal using GitHub's CLI read more about it at https://cli.github.com/

Once this is installed I create a release with this command:

gh release create v1.0.0

Press enter and a prompt will ask you the following questions.

Go through and then select Publish release press enter.

Now a release has been created on GitHub

Publish package

In order to be able to install the package on your applications and not have to tell composer where to find the package from a local folder the package can be set-up on a website called Packagist https://packagist.org/packages/submit

Enter the URL to your package on GitHub and press Check. If there are other packages with the same name Packagist will show them, if you're sure you want to publish your version press submit to continue.

Once completed the page will show your package:

In order for the package to be installable ensure you've issued a release on GitHub otherwise you may get this error:

Could not find a version of package dcblogdev/laravel-tags matching your minimum-stability (stable). Require it with
an explicit version constraint allowing its desired stability.

Resources

For more details on Laravel Package development read https://www.laravelpackage.com

0 comments
Add a comment