schleuse/dindent
Dindent is a regex-based HTML beautifier for PHP that indents markup from template engines without rebuilding, sanitizing, or otherwise altering the document—unlike DOM or Tidy. Ideal for debugging messy output. Install via Composer: schleuse/dindent.
Installation (PHP 8.2+ required):
composer require schleuse/dindent:^3.1
No additional configuration is required—just autoload via Composer.
New in v3.1.0 (2026-10-03): bug-fix release
e130cb1— "Reset temporary replacements between calls." If you pinned^3.0and share a process across manyindent()calls, bump the constraint to^3.1(see the Gotchas section before you rely on repeated calls). No API changes, no deprecations.
First Use Case (One-line mode, introduced in v3.0):
use Schleuse\Dindent\Dindent;
$dirtyHtml = '<div><span>Hello</span></div><p>World</p>';
// Standard indentation
$indented = Dindent::indent($dirtyHtml);
// Outputs:
// <div>
// <span>
// Hello
// </span>
// </div>
// <p>
// World
// </p>
// One-line mode (compact output)
$oneLine = Dindent::indent($dirtyHtml, ['oneLine' => true]);
// Outputs: <div><span>Hello</span></div><p>World</p>
Where to Look First:
v3.1.0 tag.Dindent::indent() method docs (supports oneLine, skipScripts, extractPre, and handles <style> tags).<pre>, multi-line comments, repeated invocations).Debugging HTML Responses (One-line mode for APIs):
// In a controller or middleware (compact for APIs)
$responseHtml = $response->getContent();
$indentedHtml = Dindent::indent($responseHtml, ['oneLine' => $request->wantsJson()]);
Log::debug('Indented HTML:', ['html' => $indentedHtml]);
Testing Views (Handle <style> and <pre> tags):
// In a PHPUnit test (assert style tags are preserved)
$viewOutput = $this->get('/some-route')->getContent();
$indented = Dindent::indent($viewOutput);
$this->assertStringContainsString('<style>', $indented);
$this->assertStringContainsString(' <pre>', $indented); // Indented but not broken
Batch / Queue Processing (safe as of v3.1.0):
// app/Jobs/FormatHtmlFile.php — one job, many files, same PHP process
public function handle(): void
{
foreach ($this->paths as $path) {
$html = file_get_contents($path);
// Pre-v3.1.0 this loop could leak placeholder state from file N
// into file N+1. Fixed in v3.1.0 — replacements reset per call.
file_put_contents($path, Dindent::indent($html, [
'oneLine' => false,
'extractPre' => true,
]));
}
}
Pre-commit Hooks (Optimized for speed):
// In an Artisan command
Artisan::command('dindent:fix', function () {
foreach ($this->argument('paths') as $file) {
$html = file_get_contents($file);
file_put_contents($file, Dindent::indent($html, ['oneLine' => true]));
}
});
Blade Directives (Support for <style> and one-line mode):
Blade::directive('indent', function ($expression) {
return "<?php echo \\Schleuse\\Dindent\\Dindent::indent({$expression}, ['oneLine' => false]); ?>";
});
Usage in Blade:
@indent($unformattedHtml) <!-- Standard indentation -->
@php echo \Schleuse\Dindent\Dindent::indent($html, ['oneLine' => true]); @endphp <!-- Compact -->
API Responses (Dynamic indentation based on request):
public function handle($request, Closure $next)
{
$response = $next($request);
if ($response->headers->get('Content-Type') === 'text/html') {
$options = $request->wantsJson() ? ['oneLine' => true] : [];
$response->setContent(Dindent::indent($response->getContent(), $options));
}
return $response;
}
Long-Running Workers / Octane (v3.1.0 is the target here):
// app/Providers/AppServiceProvider.php
// Under Octane or a queue worker the process is reused for thousands of
// requests. Prior to v3.1.0, temporary replacements from a previous
// invocation could survive into the next one. On v3.1.0+ this is safe;
// on v3.0.x, resolve a fresh instance per request instead.
Artisan Commands (Extract <pre> and <textarea>):
Artisan::command('dindent:fix', function () {
foreach ($this->argument('paths') as $file) {
$html = file_get_contents($file);
$indented = Dindent::indent($html, [
'oneLine' => false,
'extractPre' => true, // Preserve <pre> content
]);
file_put_contents($file, $indented);
}
});
PHP 8.2 Requirement (⚠️ Breaking from v2):
php.ini or use a PHP 8.2+ runtime (Laravel Valet, Docker, [email protected]).State Leaking Between Calls — fixed in v3.1.0 (⚠️ the reason to upgrade):
<pre>/<script>/<style> content by swapping it out for placeholders and restoring it afterwards. Before e130cb1, those replacements were not reset between calls, so a second indent() in the same process could restore placeholders from the first call — producing truncated or duplicated content, or placeholder tokens leaking into output.artisan commands looping over files, or multiple indent() calls inside one test method.// v3.0.x workaround
$indented = (new Dindent())->indent($html);
Self-Closing Tags in One-Line Mode:
Dindent preserves self-closing tags (e.g., <img/>) even in compact mode:
$html = '<div><img src="x" /><span>Text</span></div>';
Dindent::indent($html, ['oneLine' => true]);
// Outputs: <div><img src="x" /><span>Text</span></div>
<style> and <script> Handling:
<style> tags are now properly indented (previously ignored).skipScripts to opt out:
Dindent::indent($html, ['skipScripts' => true]);
<pre> and <textarea> Extraction:
<pre> or <textarea> is preserved as-is (not indented).<pre> content, use extractPre: false:
Dindent::indent($html, ['extractPre' => false]);
Performance in One-Line Mode:
Repeated-Call Regression Test (the exact scenario v3.1.0 fixed):
public function test_repeated_calls_are_isolated()
{
$first = '<div><pre> keep this </pre></div>';
$second = '<div><p>plain</p></div>';
Dindent::indent($first);
$out = Dindent::indent($second);
$this->assertStringContainsString('<p>plain</p>', $out);
$this->assertStringNotContainsString('keep this', $out); // no bleed-through
}
Edge Cases (multi-line comments and <pre>):
$html = <<<HTML
<div>
<!--
Multi-line
comment
-->
<pre> Preserve this indentation!
</pre>
</div>
HTML;
$indented = Dindent::indent($html);
// Multi-line comments and <pre> content remain intact.
Logging Differences:
$original = '<div>Test</div>';
$standard = Dindent::indent($original);
$compact = Dindent::indent($original, ['oneLine' => true]);
$this->assertNotEquals($standard, $compact, 'Modes produce different outputs!');
Custom Indentation with One-Line Mode:
class CustomDindent extends Dindent {
public static function indent($html, array $options = []) {
$options['indent'] = ' '; // 4 spaces
$options['oneLine'] = false;
return parent::indent($html, $options);
}
}
Extension Points (extractPre and skipScripts):
Dindent::indent($html, [
'extractPre' => true, // Default: preserve <pre> content
'skipScripts' => false, // Default: indent <script> and <style>
]);
Composer Constraint:
^3.0 will resolve to 3.1.x on a fresh composer update, but a committed lockfile on 3.0.0 will not move. Run composer update schleuse/dindent explicitly — the only change is the statefulness fix, so the blast radius is small.
Visual Diffs for One-Line Mode:
echo "Standard:"; echo "$standardHtml" | diff -u - <(echo "$compactHtml")
CI Integration (test both modes):
public function test_indentation_modes()
{
$html = '<div><p>Test</p></div>';
$standard = Dindent::indent($html);
$compact = Dindent::indent($html, ['oneLine' => true]);
$this->assertStringContainsString("\n <p>", $standard);
$this->assertStringNotContainsString("\n", $compact);
}
Partial Indentation with <pre>:
Indent everything except <pre> blocks, then assert they survived byte-for-byte:
preg_match_all('/<pre>(.*?)<\/pre>/s', $html, $preBlocks);
$indented = Dindent::indent($html, ['extractPre' => true]);
foreach ($preBlocks[1] as $block) {
$this->assertStringContainsString($block, $indented);
}
Pin the Fix in CI:
Add a smoke test that calls indent() twice in the same process (see Debugging) so a future downgrade or lockfile drift can't silently reintroduce the v3.0.x behaviour.
How can I help you explore Laravel packages today?