Search by

schrojf / laravel-papers

schrojf

Laravel papers package

Package info

github.com/schrojf/laravel-papers

pkg:composer/schrojf/laravel-papers

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 3

v0.2.3 2026-10-28 22:07 UTC

This package is auto-updated.

Last update: 2026-11-02 06:58:16 UTC


README

⚠️ Experimental Package — Use With Caution

Laravel Papers is still in early development and considered experimental. While it is functional and may be used in production environments, please be aware of the following:

  • The API is not stable and may change significantly in upcoming releases.
  • Documentation may be incomplete or subject to improvement.
  • Backward compatibility between minor versions is not guaranteed.
  • Use at your own risk — review the source code and test thoroughly before deploying in critical systems.

Feedback, issues, and contributions are very welcome as the package evolves. If you're using it in production, consider locking to a specific version.

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

Laravel Papers is a lightweight and flexible package for building and displaying custom debug or reporting pages — called Papers — within your Laravel application. Each Paper can aggregate and display structured or unstructured data from various sources such as databases, services, or internal logic.

Originally created as an experimental project, Laravel Papers is ideal for internal tools, dashboards, and developer-oriented views that are simple to register and use.

Demo paper page with renderable components

Features

  • Create dynamic reporting pages using reusable components
  • Render structured data using tables and data panels
  • Handle forms and buttons with CSRF protected actions
  • Find papers quickly with a keyboard friendly filter
  • Secure access using Laravel's Gate authorization

Requirements

  • Laravel Framework 9.x, 10.x, 11.x, or 12.x
  • PHP version 8.2, 8.3 or 8.4
  • Modern web browser

Installation

Install the package via Composer:

composer require schrojf/laravel-papers

Publish the configuration file (optional):

php artisan vendor:publish --tag="papers-config"

This is the default config/papers.php file:

return [

    'middleware' => [
        'web',
        \Schrojf\Papers\Http\Middleware\AuthorizePapers::class,
    ],

    'api_middleware' => [
        'api',
        \Schrojf\Papers\Http\Middleware\AuthorizePapers::class,
    ],

];

Publish the views (optional):

php artisan vendor:publish --tag="papers-views"

Usage

Registering Papers

Define and register your Paper classes in a service provider, such as AppServiceProvider.php:

use Schrojf\Papers\Papers;
use App\Papers\MyReportPaper;

public function boot(): void
{
    Papers::register([
        MyReportPaper::class,
    ]);
}

Finding Papers

The navigation and the index page have a filter for finding papers by their name, description or URL handler. Press / to focus it, the arrow keys and Enter to open a paper, and Escape to clear it. The filter ignores letter case and diacritics, so stitky finds "Štítky", and it keeps the query while you browse papers in the same browser tab.

Add keywords to find a paper by other words too, such as synonyms or translations of its name:

class SalesByBrand extends Paper
{
    public static $keywords = ['revenue', 'tržby'];
}

Access Control

By default, access to Papers is restricted in non-local environments. Define a Gate to control who can view them:

use Illuminate\Support\Facades\Gate;
use App\Models\User;

public function boot(): void
{
    Gate::define('viewPapers', function (User $user) {
        return in_array($user->email, [
            'admin@example.com',
        ]);
    });
}

Favicon and Head

Papers pages use your app's /favicon.ico by default. Set a favicon of their own, so their browser tabs stand out from the rest of your app:

use Schrojf\Papers\Papers;

public function boot(): void
{
    Papers::favicon(asset('favicon-green.png'));
}

Add any other HTML to the head of papers pages, such as more icons, a web manifest or a theme color. It is not escaped, and a closure is called whenever a page is rendered:

Papers::head(fn () => view('papers.head'));
{{-- resources/views/papers/head.blade.php --}}
<link rel="icon" href="{{ asset('favicon-green.ico') }}" sizes="16x16 32x32 48x48">
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon-green.png') }}">
<link rel="manifest" href="{{ asset('papers.webmanifest') }}">

Creating a Paper

A Paper class extends Schrojf\Papers\Paper and defines its sections as callable functions. Each section returns content such as strings, arrays, or component instances like TableContent or DataPanelContent.

Here’s a full example:

<?php

namespace App\Papers;

use RuntimeException;
use Schrojf\Papers\Contents\DataPanelContent;
use Schrojf\Papers\Contents\TableContent;
use Schrojf\Papers\Paper;

class DemoPaper extends Paper
{
    public static $description = 'This is a demo paper showing various content types.';
    
    public function sections(): array
    {
        return [
        
            'Simple Text' => fn () => ['Hello', 'World!'],

            'Empty' => function () {
                return null;
            },

            'With function call' => function () {
                $rand = rand(1, 100);

                return 'Random number: ' . $rand;
            },

            'Table Example' => fn () => [
                'This section demonstrates a responsive table.',
                TableContent::withHeaders(['ID', 'Name', 'Status', 'Score'])
                    ->row([1, 'Alice', 'Active', 85])
                    ->row([2, 'Bob', null, 92])
                    ->row([3, 'Charlie', 'Inactive', null])
                    ->row([4, null, 'Active', 78]),
            ],

            'Section with Data Panel Component' => function () {
                return DataPanelContent::make()
                    ->title('User Profile')
                    ->item('Name', 'Jane Doe')
                    ->item('Email', 'jane.doe@example.com')
                    ->item('Phone', '+1 234 567 8901')
                    ->item('Role', 'Administrator')
                    ->item('Status', 'Active');
            },

            'Info' => function () {
                return 'Next section will raise an exception and the Report section will not be run.';
            },

            'Exception Demo' => function () {
                throw new RuntimeException('This is a simulated error.');
            },

            'Unreachable Section' => fn () => 'This will not be rendered if the previous section fails.',
            
        ];
    }
}

Full Width

Paper pages are limited in width to keep text readable. Papers with wide content, such as tables with many columns, may use the full width of the screen:

class SalesReport extends Paper
{
    public static $fullWidth = true;
}

Fitting Tables

Tables keep each cell on a single line and scroll horizontally when they are wider than their section. Call fit() to fit a table to the width of its section instead. The table then spans the whole section, and its columns shrink by wrapping their content: headers wrap between words only, while values may break anywhere, so long URLs or references don't widen the table. It scrolls only when its content can't shrink any further, e.g. a wide image, or headers which don't fit a narrow screen.

TableContent::withHeaders(['ID', 'Product', 'URL'])
    ->rows($rows)
    ->fit();

Actions

Sections are built for every GET request of the paper page, so they must never change any data. A GET request is not protected against CSRF — a link or an image on any other website opened by a signed-in user could make it.

To change data, define an optional action() method on the paper and submit a form from a section to the paper's URL with the POST method. The POST request runs in the web middleware group, so it requires a CSRF token. Papers without an action() method answer it with 405 Method Not Allowed.

Dependencies of the action() method are resolved from the container, so you may type-hint any service or your own form request. What the method returns decides what the browser gets:

  • Nothing — the paper page is rendered right away by the same paper instance, so sections can show what the action stored on it.
  • A response — a redirect, a download, JSON or anything else Laravel can send is returned as it is.
<?php

namespace App\Papers;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\HtmlString;
use Schrojf\Papers\Paper;

class ProductPrices extends Paper
{
    protected ?int $updated = null;

    public function sections(): array
    {
        $sections = [
            'Form' => fn () => new HtmlString('
                <form method="POST" action="'.e(route('papers.action', static::handler())).'">
                    '.csrf_field().'
                    <input type="number" name="discount" min="1" max="90">
                    <button type="submit" name="intent" value="discount">Apply discount</button>
                    <button type="submit" name="intent" value="export">Export prices</button>
                </form>
            '),
        ];

        if (! is_null($this->updated)) {
            $sections['Result'] = fn () => "Prices of {$this->updated} products were updated.";
        }

        return $sections;
    }

    public function action(Request $request): mixed
    {
        if ($request->input('intent') === 'export') {
            return response()->streamDownload(function () {
                echo Product::query()->pluck('price', 'id')->toJson();
            }, 'prices.json');
        }

        $validated = $request->validate([
            'discount' => ['required', 'integer', 'between:1,90'],
        ]);

        $products = Product::all();

        foreach ($products as $product) {
            $product->update([
                'price' => round($product->price * (100 - $validated['discount']) / 100, 2),
            ]);
        }

        $this->updated = $products->count();

        return null;
    }
}

Refreshing a page rendered after the action submits the form again, which here would apply the discount twice. Return a redirect to load the paper by a GET request instead, and pass its result through the session:

return redirect()
    ->route('papers.show', static::handler())
    ->with('updated', $updated);

When the action fails validation, the browser is redirected back with the errors and the old input in the session, as anywhere else in Laravel.

Scripts in sections can call the action in the background too, e.g. for an action returning JSON. The paper page provides the token in its csrf-token meta tag:

fetch('/papers/product-prices', {
    method: 'POST',
    headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
    },
    body: JSON.stringify({ intent: 'email' }),
});

Testing

Run the test suite using:

composer test

Changelog

See CHANGELOG.md for a list of recent changes.

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for guidelines.

Security Vulnerabilities

If you discover a security vulnerability, please review our security policy for how to responsibly report it.

Credits

License

This package is open-sourced software licensed under the MIT license.