Table of Contents
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.
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
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:
src
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:', '"', '”', '’', ' '];
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