Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Trailing Slash Laravel Package

middlewares/trailing-slash

Laravel middleware that normalizes trailing slashes by redirecting URLs to your preferred format, with configurable behavior, query-string preservation, and clean handling of requests that already match.

View on GitHub
Deep Wiki
Context7

Getting Started

What it is (and isn't). middlewares/trailing-slash is a ~50-line PSR-15 middleware. It inspects $request->getUri()->getPath(), compares it against your chosen mode (add or remove), and if the path is non-canonical returns a 301 with a Location header (query string preserved). Otherwise it calls the handler. There is no Laravel service provider, no config file, no facade — it is not a Laravel package in the usual sense.

Install.

composer require middlewares/trailing-slash
# you also need a PSR-7 implementation + PSR-17 factory for any bridge work
composer require nyholm/psr7

First look. Read src/TrailingSlash.php end to end before wiring anything — it is short enough that the source is the documentation. Confirm the exact mode API in the README (add() / remove(), or a constructor flag); it has changed shape across Oscar Otero's middleware packages, and the docs in this listing are thin.

Minimal standalone sanity check (do this before touching Laravel):

use Middlewares\TrailingSlash;
use Middlewares\Utils\Dispatcher;

$dispatcher = new Dispatcher([
    (new TrailingSlash())->remove(),   // verify exact method in the README
    fn ($request) => new Response(200),
]);

$response = $dispatcher->dispatch(new ServerRequest('GET', '/about/?page=2'));
// 301, Location: /about?page=2

First real use case in Laravel: SEO canonicalisation. Laravel's router already treats /about and /about/ as the same route, so this is not about fixing 404s — it's about making sure only one URL form is ever served or linked, so crawlers and analytics don't split the page. If that's your goal and you have no PSR-15 stack, honestly assess whether you want the dependency: a native Laravel middleware gets you the same result in ~20 lines. This package earns its place when you already run PSR-15 middleware (Slim/Mezzio app, a shared package targeting multiple frameworks, or an existing middlewares/utils pipeline).

Implementation Patterns

Pattern 1 — Native Laravel middleware (the pragmatic default)

If the only goal is canonical trailing slashes in a Laravel app, skip the bridge:

// app/Http/Middleware/CanonicalTrailingSlash.php
public function handle(Request $request, Closure $next)
{
    if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
        return $next($request);
    }

    $path = $request->getPathInfo();          // '/about/' — not the full URI
    $shouldRemove = $path !== '/' && str_ends_with($path, '/');
    $shouldAdd    = $path !== '/' && ! str_ends_with($path, '/')
                    && ! str_contains(basename($path), '.'); // skip /logo.png

    if ($shouldRemove || $shouldAdd) {
        $target = $shouldRemove ? rtrim($path, '/') : $path . '/';
        return redirect()->to($target . ($request->getQueryString() ? '?' . $request->getQueryString() : ''), 301);
    }

    return $next($request);
}

Register it globally in bootstrap/app.php (Laravel 11+) before route middleware resolves, so you never render a page at the wrong URL:

->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(\App\Http\Middleware\CanonicalTrailingSlash::class);
})

Pattern 2 — Bridging the real PSR-15 middleware into Laravel

If you want to use this package specifically (e.g. consistency with a Slim service, or you're evaluating it for a shared library), you don't need a full PSR-7 round-trip of the downstream response. The middleware is a pure gate: it either returns a redirect or delegates to the handler. Use an exception as the pass-through sentinel:

final class PassThrough extends \RuntimeException {}

public function handle(Request $request, Closure $next)
{
    $psr17 = new \Nyholm\Psr7\Factory\Psr17Factory();

    // Request conversion — keep it minimal, only the URI + method matter here.
    $psrRequest = $psr17->createServerRequest($request->method(), $request->fullUrl());

    $handler = new class implements RequestHandlerInterface {
        public function handle(ServerRequestInterface $request): ResponseInterface
        {
            throw new PassThrough();
        }
    };

    $middleware = (new TrailingSlash())->remove();

    try {
        $response = $middleware->process($psrRequest, $handler);
    } catch (PassThrough) {
        return $next($request);   // canonical already — carry on
    }

    return redirect()->to($response->getHeaderLine('Location'), 301);
}

This avoids pulling in symfony/psr-http-message-bridge and keeps the middleware stateless. It assumes the middleware always delegates via the handler when no redirect is needed — true for PSR-15, but verify against the source.

Pattern 3 — Shared pipeline across Slim/Mezzio and Laravel

If the same codebase ships a PSR-15 app and a Laravel admin, keep the canonicalisation rule in one place and apply it at the outermost layer in each runtime:

// Slim / Mezzio / middlewares/utils
$app->add((new TrailingSlash())->remove());

Place it first in the stack — before routing, before body parsing, before auth. Redirecting costs nothing; routing and rendering do.

Choosing add vs remove

  • remove is the safer default. It matches how Laravel generates URLs (route('about') → /about), so links, route:list, and sitemaps already agree with it.
  • add is for directory-style sites or when your CDN/static host serves index.html under /section/. If you pick add, you must also update URL::forceRootUrl-adjacent config and your <link rel="canonical"> tag to match, or you'll create the opposite duplicate-content problem.

Testing

it('redirects trailing slashes', function () {
    $this->get('/about/?page=2')->assertStatus(301)
         ->assertRedirect('/about?page=2');
});

it('leaves canonical urls alone', function () {
    $this->get('/about')->assertOk();
});

Also add a negative test for POST /webhooks/stripe/ — it must not redirect (see Gotchas).

Gotchas and Tips

301s are cached by browsers, sometimes forever. Ship the redirect in 302 while validating on staging, or test with curl -I and curl --resolve. Once a user's browser caches a bad 301, only a cache clear fixes it — and you can't ask customers to do that.

Never redirect non-GET methods. A 301 on POST causes the client to re-issue the request as GET against the new URL, dropping the body. Webhooks, form submissions, and JSON APIs will silently break. Both the native example and the PSR-15 middleware should be gated on GET/HEAD; check whether the package does this itself, and if not, guard it in your bridge.

Laravel signed URLs break. URL::signedRoute() and URL::temporarySignedRoute() include the path in the HMAC. Redirecting /download/ → /download changes the path, so hasValidSignature() fails on the redirected request. Either exclude signed-route prefixes or ensure all signed URLs are generated in canonical form.

CSRF and sessions. A redirect on POST loses the CSRF token and the payload; see above. Also confirm the redirect response doesn't accidentally start a session or emit cookies — the PSR-15 version won't, but your bridge might if you touch $request->session().

XHR/framework requests. Exclude X-Inertia, X-Livewire, and Accept: application/json requests. Browsers follow 301s transparently for XHR, but the request method/headers can shift and Inertia will surface the redirect as a location change mid-component-lifecycle.

Sub-directory installs and proxies. The package reads $request->getUri()->getPath(), which under Laravel/Symfony depends on TrustProxies and the SCRIPT_NAME. If the app lives at /app behind a proxy, the Location header can drop the prefix. Verify with a real X-Forwarded-* header set, and keep TrustProxies configured before enabling this.

Root path. / has no sensible "trailing slash removed" form. Confirm the middleware short-circuits on / and doesn't emit Location: (empty). Test explicitly — it's a classic off-by-one in hand-rolled versions of this middleware.

Files and extensions. Removing the slash from /assets/app.css/ is harmless, but adding one to /logo.png will 301 every image on your site. If you use add mode, exclude paths with a file extension in your wrapper.

Placement order matters. Put it before routing and before anything expensive. If it runs after StartSession or Authenticate, you'll create sessions and log auth failures for requests that were only ever going to be redirected.

Don't expect it to fix 404s. Laravel's path() already trims trailing slashes for route matching, so this middleware changes canonicalisation, not resolution. If you're seeing duplicate routes in route:list, that's a route-definition problem.

Extension points are essentially nil. The class is small and single-purpose; there are no events, no config object, no callbacks. If you need path-pattern exclusions, query-string rewriting, or per-route rules, wrap it (or fork it) rather than expecting hooks. Wrapping is trivial: check your exclusion list first, then delegate.

Assess the dependency honestly. At ~50 lines, in a Laravel-only codebase the native middleware in Pattern 1 is easier to read, test, and debug — no PSR-7 factory, no bridge, no RuntimeException sentinel. The package pays off when you already have a PSR-15 pipeline or need the same behaviour across frameworks. Adopt it for architectural consistency, not for the redirect logic itself.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings