The main point of attributes is to add metadata to code that is easily parseable and can think for itself when you ask for it. Up to now most devs were probably doing this using the PHP docblock thing - those double star multi-line comments. We'd fill them with @tags and then parse that data with Reflection and probably some regular expressions to make any sense of the blob of text.
We would fill those PHP docblocks up with all sorts of text, ranging from literally re-declaring the function, its arguments and return, providing details about what the function does, what error states might exist. Here in this blog app I was even using them to provide metadata for my "Restful" JSON API so I could remember what input it expects, but also so you can ask it for help, and it can respond with what endpoints it has and the inputs it wants using the exact same comment data. Here is what that looked like:
/**
 * @info Create a new blog.
 * @input String Title
 * @input String Alias
 * @error 1 Blog must have a title.
 * @error 2 Blog must have an alias.
 * @error 3 A Blog with this URL already exists.
 * @error 100 Database shit itself inserting blog.
 * @error 101 Database shit itself inserting blog ownership.
 */

final public function
EntityPost(): Void { ... }
Parsing this is a mini disaster and it only supports 3 tags. If you are using a project that used PHP docblock annotations to do things like autowire, auto model, or provide additional feedback, it was doing something like this, too, at some point, somewhere, in some library. This is the code that builds an array of data that my API would json_encode and dump, describing its own API when you ask it for help by reusing the content from the PHP docblock. Do yourself a favour, don't try and trace the arrays back to their origins to understand what the final array contains. Instead accept it for the dumpster fire it is to appreciate how much clearer it will become after attributes.
$Doc = $Reflect->GetDocComment();

if($Doc) {
	$Vars = [ 'Info' => NULL, 'Args' => [], 'Errors' => [] ];

	preg_match_all('/\@input ([^\h]+) ([^\h]+)$/ms',$Doc,$Inputs);
	preg_match_all('/\@info (.+?)$/ms',$Doc,$Info);
	preg_match_all('/\@error ([\d]+) (.+?)$/ms',$Doc,$Errors);

	if(array_key_exists(0,$Info[1]))
	$Vars['Info'] = $Info[1][0];

	foreach($Inputs[0] as $Key => $Val)
	$Vars['Args'][$Inputs[2][$Key]] = $Inputs[1][$Key];

	foreach($Errors[0] as $Key => $Val)
	$Vars['Errors'][$Errors[1][$Key]] = $Errors[2][$Key];

	$Output[$Endpoint][$Verb] = count($Vars) ? $Vars : NULL;
}
Personally I hate the old docblock format enough that I never used it except in this one case of the self describing API. But now we finally have annotations (I mean, attributes) in PHP. The first step is to rewrite the docblock from my first example using the attribute format. Attributes are really just classes, what we are writing is a bunch of lines that point to a class and provide any data that class needs to construct itself.
#[Atlantis\Meta\Info('Create a new blog.')]
#[Atlantis\Meta\Parameter('Title','String')]
#[Atlantis\Meta\Parameter('Alias','String')]
#[Atlantis\Meta\Error(1,'Blog must have a title.')]
#[Atlantis\Meta\Error(2,'Blog must have an alias.')]
#[Atlantis\Meta\Error(3,'A blog with this URL already exists.')]
#[Atlantis\Meta\Error(100,'Database shit itself inserting blog.')]
#[Atlantis\Meta\Error(101,'Database shit itself inserting blog ownership.')]
final public function
EntityPost():
Void { ... }
With my metadata converted from the docblock to attributes now it needs the actual attribute classes. There is nothing special about them other than one thing - the class themselves get attributed as an attribute.
<?php

namespace Atlantis\Meta;
use Attribute;

#[Attribute]
class Info {
	
	public
	String $Message;

	public function
	__Construct(String $Message) {
		$this->Message = $Message;
		return;
	}

}

// sidenote, this would have been a prime example for PHP 8's new constructor
// property promotion feature but after using it i have decided i absoutely
// hate it.
My Atlantis\Meta\Info class expects a string, which will be given to it from the #[Atlantis\Meta\Info('...')] attribute. The only thing of note here is the #[Attribute] Attribute. Attributes must have the #[Attribute] Attribute or else it won't let you use the class as an Attribute. At first it might not make sense why they did this instead of something more meaningful like attribute Info {...} but lets continue, lol.
One note to save you debugging time - notice how I did use Attribute there? Since it is a class reference it follows the same rules as any other class reference. My choices were either to use or to type #[\Attribute] and since I loath the escape sequence character being our Namespace operator and more so it being the first character of a call, I elect to use.
We should also look at my Atlantis\Meta\Error attribute - by default, PHP attributes only allow you to use them once per property, class, method, etc. Once per "thing" - but I needed multiple input attributes and multiple error attributes.
<?php

namespace Atlantis\Meta;
use Attribute;

#[Attribute(Attribute::IS_REPEATABLE|Attribute::TARGET_ALL)]
class Error {

	public
	Int $Code;
	
	public
	String $Message;
	
	public function
	__Construct(Int $Code=0, String $Message='OK') {
		$this->Code = $Code;
		$this->Message = $Message;
		return;
	}

}
Other than this attribute taking two arguments, the main difference here is the #[Attribute] Attribute itself. This time we gave it an argument of flags. The first flag, IS_REPEATABLE, allows us to have multiple of this annotation per thing we are annotating. The second argument, TARGET_ALL, is to keep the default behaviour of allowing you to use this annotation on anything, classes, functions, whatever. If you only pass IS_REPEATABLE, then you overwrite the entire flag with literally only that option and the result is the annotation will work on nothing.
We have converted a meta docblock into a block of meta attributes, created the attribute classes, the only thing left is parsing the attributes. Recall the 2nd code example that contained the regular expressions. That has been replaced with:
$Reflect = new ReflectionMethod(static::class,$Method);
$Attribs = $Reflect->GetAttributes();
$Vars = [ 'Info' => NULL, 'Args' => [], 'Errors' => [] ];

foreach($Attribs as $Attrib) {
	$Meta = $Attrib->NewInstance();

	if($Meta instanceof Atlantis\Meta\Parameter)
	$Vars['Args'][$Meta->Name] = $Meta->Type;

	elseif($Meta instanceof Atlantis\Meta\Error)
	$Vars['Errors'][$Meta->Code] = $Meta->Message;

	elseif($Meta instanceof Atlantis\Meta\Info)
	$Vars['Info'] = $Meta->Message;
}

$Output[$Endpoint][$Verb] = count($Vars) ? $Vars : NULL;
Instead of using reflection to get the text content of a comment, we use it to ask for a list of attributes and loop over them. This code still only supports 3 tags, but rather than parsing a bunch of strings with expressions we can now just easy instanceof test the attributes, making clear logical decisions. To add support for a new type of tag/attribute I would much rather see and add to this than the previous way.
Just for context here is a screenshot of why I even went through all that work. Firstly of course it was so I had reminders in the code when needing to look something up, but also because the entire point originally was to allow the API to help you help it.

REusing Attribute Data in-app

Really, that was the bare minimum to swap out the docblock for attributes with the minimal amount of changes to the code around them. One code pattern that annoys me is duplication - in this case, my docblocks/attributes will have a list of error codes, and what those codes mean. But when I need to actually emit those errors I would have to type the error text in again in the Quit call.
if(!$Title)
$this->Quit(1,'Blog must have a title.');
This is super annoying! If I copy pasted a typo into both the documentation and the code It would have to be edited twice. Or even if the error itself just needed to be clarified we would have to edit twice. I elected to make Quit a little smarter.
public function
Quit(Int $ErrNum=0, String $Message='OK'):
Void {
/*//
@date 2020-05-22
so long and thanks for all the fish.
//*/

	($this->Surface)
	->Set('Error',$ErrNum)
	->Set('Message',$Message);

	exit($ErrNum);
	return;
}
public function
Quit(Int $ErrNum=0, String $Message='OK'):
Void {
/*//
@date 2020-05-22
so long and thanks for all the fish.
//*/

	if($ErrNum !== 0 && $Message === 'OK') {
		$Attrib = NULL;
		$Message = 'Err';
		$Reflect = new ReflectionMethod(
			static::class,
			(new Exception)->GetTrace()[1]['function']
		);

		foreach($Reflect->GetAttributes('Atlantis\\Meta\\Error') as $Attrib) {
			$Meta = $Attrib->NewInstance();

			if($Meta->Code === $ErrNum) {
				$Message = $Meta->Message;
				break;
			}
		}
	}

	($this->Surface)
	->Set('Error',$ErrNum)
	->Set('Message',$Message);

	exit($ErrNum);
	return;
}
So now when I want to quit with an error condition I just call $this->Quit(Integer) and it will end the app using the error message from the annotations of the method that called it if it exists.
Is this a good idea? Honestly, I don't know yet. The Reflection API has historically been slow AF, but it has been a long time since it was introduced. Now I've pushed it into an actual production use case as one of the applications main possible pathways so we will see. It is obviously clever, but the jury is still out on if it was smart. I feel like surely it has made some optimization progress since they intend it to be the primary means of interacting with attributes, right?

Possible Next Steps

Depending on if this works out I will probably want to add basic annotation convenience functions to some super low level parent class if it seems like using them as part of our normal behaviours is safe. Something like Object::GetClassAnnotations() that automatically fetches static::class list of instantiated attributes so I don't have to new ReflectionClass and foreach(GetAttributes()) myself 9001 times a project. And something like Object::GetMethodAnnotations() to automatically commit that hilarious exception hack contained into a single place.