Global vs crawler context
Section titled “Global vs crawler context”The builder tracks a current context. Until you call userAgent(), every rule
you add is global and renders under User-agent: *:
use Fkrzski\RobotsTxt\RobotsTxt;
$robots = new RobotsTxt();
echo $robots ->disallow('/admin') ->allow('/public') ->toString();User-agent: *Disallow: /adminAllow: /publicCalling userAgent() switches the context: every rule after it belongs to that
crawler until you switch again.
use Fkrzski\RobotsTxt\Enums\CrawlerEnum;
echo $robots ->disallow('/admin') // global ->userAgent(CrawlerEnum::GOOGLE) ->disallow('/google-only') // Googlebot ->userAgent(CrawlerEnum::BING) ->disallow('/bing-only') // Bingbot ->toString();User-agent: *Disallow: /admin
User-agent: GooglebotDisallow: /google-only
User-agent: BingbotDisallow: /bing-onlyuserAgent() vs forUserAgent()
Section titled “userAgent() vs forUserAgent()”userAgent() sets the context and leaves it set — every following rule sticks to
that crawler. forUserAgent() scopes a group of rules to a crawler inside a
closure, then restores the previous context when the closure returns.
The same policy, written both ways:
$robots ->disallow('/admin') ->userAgent(CrawlerEnum::GOOGLE) ->disallow('/private') ->crawlDelay(10);$robots ->disallow('/admin') ->forUserAgent(CrawlerEnum::GOOGLE, function (RobotsTxt $robots): void { $robots ->disallow('/private') ->crawlDelay(10); });Both render the same file:
User-agent: *Disallow: /admin
User-agent: GooglebotDisallow: /privateCrawl-delay: 10The difference shows up on the next call. Append allow('/public') to each chain
and they diverge:
$robots ->disallow('/admin') ->userAgent(CrawlerEnum::GOOGLE) ->disallow('/private') ->crawlDelay(10) ->allow('/public'); // still Googlebot — the context stayed setUser-agent: *Disallow: /admin
User-agent: GooglebotDisallow: /privateCrawl-delay: 10Allow: /public$robots ->disallow('/admin') ->forUserAgent(CrawlerEnum::GOOGLE, function (RobotsTxt $robots): void { $robots ->disallow('/private') ->crawlDelay(10); }) ->allow('/public'); // back to global — the context was restoredUser-agent: *Disallow: /adminAllow: /public
User-agent: GooglebotDisallow: /privateCrawl-delay: 10Output ordering
Section titled “Output ordering”toString() always emits sections in a fixed order, regardless of the order you
called the methods:
- Global rules (under
User-agent: *), if any - Each crawler block, in the order its crawler was first referenced
- All sitemaps, last
Blocks are separated by a blank line. Note the two sitemaps added at opposite ends of the chain:
$robots = new RobotsTxt();
echo $robots ->disallow('/admin') ->sitemap('https://example.com/sitemap1.xml') ->forUserAgent(CrawlerEnum::GOOGLE, function (RobotsTxt $robots): void { $robots ->disallow('/google-private') ->allow('/public/*'); }) ->forUserAgent(CrawlerEnum::BING, function (RobotsTxt $robots): void { $robots ->disallow('/bing-private') ->crawlDelay(5); }) ->sitemap('https://example.com/sitemap2.xml') ->toString();User-agent: *Disallow: /admin
User-agent: GooglebotDisallow: /google-privateAllow: /public/*
User-agent: BingbotDisallow: /bing-privateCrawl-delay: 5
Sitemap: https://example.com/sitemap1.xmlSitemap: https://example.com/sitemap2.xmlWriting to disk
Section titled “Writing to disk”toFile() renders the content and writes it to a file. With no argument it writes
robots.txt to the current working directory:
$robots->toFile(); // ./robots.txt$robots->toFile('/var/www/html/robots.txt'); // custom pathIt returns true on success and throws a RuntimeException when the target
directory does not exist, or the directory or an existing file is not writable.
The file has to land in the document root, so the path depends on your stack:
$robots->toFile(public_path('robots.txt'));$robots->toFile($this->getParameter('kernel.project_dir').'/public/robots.txt');$robots->toFile(__DIR__.'/public/robots.txt');Directorypublic
- robots.txt written by
toFile() - index.php
- robots.txt written by
Directorysrc/
- …
- composer.json
Serving it dynamically
Section titled “Serving it dynamically”The policy can also be rendered per request — useful when it depends on the environment:
use Fkrzski\RobotsTxt\RobotsTxt;
Route::get('/robots.txt', function () { $robots = new RobotsTxt();
if (app()->isProduction()) { $robots->disallow('/admin')->sitemap(url('/sitemap.xml')); } else { $robots->disallowAll(); }
return response($robots->toString(), 200, ['Content-Type' => 'text/plain']);});use Fkrzski\RobotsTxt\RobotsTxt;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;
final class RobotsController{ #[Route('/robots.txt', name: 'robots')] public function __invoke(): Response { $robots = (new RobotsTxt()) ->disallow('/admin') ->sitemap('https://example.com/sitemap.xml');
return new Response($robots->toString(), 200, ['Content-Type' => 'text/plain']); }}<?php
use Fkrzski\RobotsTxt\RobotsTxt;
require __DIR__.'/../vendor/autoload.php';
header('Content-Type: text/plain');
echo (new RobotsTxt()) ->disallow('/admin') ->sitemap('https://example.com/sitemap.xml') ->toString();Testing
Section titled “Testing”The builder does no I/O beyond toFile(), so there is nothing to fake — assert on
the rendered string:
it('blocks the admin panel', function (): void { $robots = (new RobotsTxt())->disallow('/admin')->allow('/public');
expect($robots->toString())->toBe(<<<'TXT' User-agent: * Disallow: /admin Allow: /public TXT);});public function test_it_blocks_the_admin_panel(): void{ $robots = (new RobotsTxt())->disallow('/admin')->allow('/public');
$this->assertSame(<<<'TXT' User-agent: * Disallow: /admin Allow: /public TXT, $robots->toString());}Covering toFile() means handing it a path. Without one it resolves to
getcwd() — under a test runner that is the project root, so the suite drops a
stray robots.txt there, or overwrites a real one:
$path = sys_get_temp_dir().'/robots-'.uniqid().'.txt';
$robots->toFile($path); // never a bare toFile() in a test